Update default colors

This commit is contained in:
MM20
2026-02-02 22:24:15 +01:00
parent 06437df84d
commit 312e712656
72 changed files with 8958 additions and 3197 deletions

View File

@@ -29,7 +29,7 @@ fun GuardedPreference(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.extraSmall)
.background(MaterialTheme.colorScheme.surface)
.background(MaterialTheme.colorScheme.surfaceBright)
) {
if (locked) {
Banner(

View File

@@ -23,7 +23,7 @@ fun <T> ListPreference(
items: List<ListPreferenceItem<T>>,
value: T,
summary: String? = items.firstOrNull { value == it.value }?.label,
containerColor: Color = MaterialTheme.colorScheme.surface,
containerColor: Color = MaterialTheme.colorScheme.surfaceBright,
onValueChanged: (T) -> Unit,
enabled: Boolean = true,
itemLabel: @Composable RowScope.(item: ListPreferenceItem<T>) -> Unit = {

View File

@@ -29,7 +29,7 @@ fun Preference(
onClick: () -> Unit = {},
controls: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.surface,
containerColor: Color = MaterialTheme.colorScheme.surfaceBright,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -92,7 +92,7 @@ fun Preference(
onClick: () -> Unit = {},
controls: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.surface,
containerColor: Color = MaterialTheme.colorScheme.surfaceBright,
) {
Preference(
title = {
@@ -121,7 +121,7 @@ fun Preference(
onClick: () -> Unit = {},
controls: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.surface,
containerColor: Color = MaterialTheme.colorScheme.surfaceBright,
) {
Preference(
title,

View File

@@ -28,13 +28,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -157,8 +153,8 @@ fun PreferenceScreen(
content = content,
verticalArrangement = verticalArrangement,
contentPadding = PaddingValues(
top = it.calculateTopPadding(),
bottom = it.calculateBottomPadding() + 4.dp,
top = it.calculateTopPadding() + 12.dp,
bottom = it.calculateBottomPadding() + 12.dp,
start = 12.dp,
end = 12.dp
)

View File

@@ -30,7 +30,7 @@ fun PreferenceWithSwitch(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.extraSmall)
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall)
) {
Box(
modifier = Modifier.weight(1f)

View File

@@ -38,7 +38,7 @@ fun SliderPreference(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.extraSmall)
.background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall)
.padding(
start = if (icon != null || iconPadding) 8.dp else 16.dp,
end = 16.dp,

View File

@@ -21,7 +21,7 @@ fun SwitchPreference(
value: Boolean,
onValueChanged: (Boolean) -> Unit,
enabled: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.surface,
containerColor: Color = MaterialTheme.colorScheme.surfaceBright,
) {
Preference(
title = title,

View File

@@ -24,7 +24,7 @@ fun Color.Companion.hct(hue: Float, chroma: Float, tone: Float): Color {
fun Color.atTone(tone: Int): Color {
return Color(
Hct.fromInt(this.toArgb()).apply {
this.tone = tone.toDouble()
this.setTone(tone.toDouble())
}.toInt()
)
}

View File

@@ -90,7 +90,7 @@ fun ColorSchemePreferenceCategory(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceContainerLowest,
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.shapes.extraSmall
)
.horizontalScroll(rememberScrollState())

View File

@@ -65,15 +65,15 @@ import de.mm20.launcher2.ui.locals.LocalDarkTheme
import de.mm20.launcher2.ui.theme.colorscheme.darkColorSchemeOf
import de.mm20.launcher2.ui.theme.colorscheme.lightColorSchemeOf
import de.mm20.launcher2.ui.theme.colorscheme.systemCorePalette
import hct.Hct
import kotlinx.serialization.Serializable
import palettes.CorePalette
import scheme.SchemeExpressive
import java.util.UUID
import kotlin.uuid.Uuid
@Serializable
data class ColorSchemeSettingsRoute(
@Serializable(with = UUIDSerializer::class) val id: UUID
): NavKey
) : NavKey
@Composable
fun ColorSchemeSettingsScreen(themeId: UUID) {
@@ -91,7 +91,7 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
val previewColorScheme =
theme?.let { if (previewDarkTheme) darkColorSchemeOf(it) else lightColorSchemeOf(it) }
val systemPalette = systemCorePalette()
val systemPalette = systemCorePalette(dark)
val mergedCorePalette by remember(theme?.corePalette, systemPalette) {
derivedStateOf {
@@ -175,7 +175,9 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
defaultValue = systemPalette.secondary,
autoGenerate = {
theme!!.corePalette.primary?.let {
CorePalette.of(it).a2.keyColor.toInt()
SchemeExpressive(
Hct.fromInt(it), false, 0.0,
).secondaryPaletteKeyColor
}
},
)
@@ -194,7 +196,9 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
defaultValue = systemPalette.tertiary,
autoGenerate = {
theme!!.corePalette.primary?.let {
CorePalette.of(it).a3.keyColor.toInt()
SchemeExpressive(
Hct.fromInt(it), false, 0.0,
).tertiaryPaletteKeyColor
}
},
)
@@ -213,7 +217,9 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
defaultValue = systemPalette.neutral,
autoGenerate = {
theme!!.corePalette.primary?.let {
CorePalette.of(it).n1.keyColor.toInt()
SchemeExpressive(
Hct.fromInt(it), false, 0.0,
).neutralPaletteKeyColor
}
},
)
@@ -232,7 +238,9 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
defaultValue = systemPalette.neutralVariant,
autoGenerate = {
theme!!.corePalette.primary?.let {
CorePalette.of(it).n2.keyColor.toInt()
SchemeExpressive(
Hct.fromInt(it), false, 0.0,
).neutralVariantPaletteKeyColor
}
},
)
@@ -251,7 +259,9 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
defaultValue = systemPalette.error,
autoGenerate = {
theme!!.corePalette.primary?.let {
CorePalette.of(it).error.keyColor.toInt()
SchemeExpressive(
Hct.fromInt(it), false, 0.0,
).errorPalette.keyColor.toInt()
}
},
)
@@ -790,7 +800,11 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Icon(painterResource(R.drawable.search_24px), null, tint = MaterialTheme.colorScheme.onSurface)
Icon(
painterResource(R.drawable.search_24px),
null,
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
@@ -1080,7 +1094,7 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
}
IconButton(
onClick = { },
onClick = { },
modifier = Modifier
.padding(end = 16.dp)
.align(Alignment.CenterVertically),

View File

@@ -35,13 +35,13 @@ fun ColorSwatch(
else 40f
}
it.apply {
this.tone = tone.toDouble()
this.setTone(tone.toDouble())
}.toInt()
})
val borderColor = Color(Hct.fromInt(color.toArgb()).let {
val tone = if (darkTheme) 30f else 80f
it.apply {
this.tone = tone.toDouble()
this.setTone(tone.toDouble())
}.toInt()
})
Box(

View File

@@ -58,7 +58,7 @@ fun CorePaletteColorPreference(
.clickable(
onClick = { showDialog = true },
)
.background(MaterialTheme.colorScheme.surface)
.background(MaterialTheme.colorScheme.surfaceBright)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {

View File

@@ -81,7 +81,7 @@ fun ThemeColorPreference(
.clickable(
onClick = { showDialog = true },
)
.background(MaterialTheme.colorScheme.surface)
.background(MaterialTheme.colorScheme.surfaceBright)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {

View File

@@ -57,7 +57,7 @@ fun CrashReportScreen(fileName: String) {
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surface)
.background(MaterialTheme.colorScheme.surfaceBright)
.horizontalScroll(
rememberScrollState()
),
@@ -80,7 +80,7 @@ fun CrashReportScreen(fileName: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.medium)
.padding(12.dp),
) {
Text(text = "Device Information", style = MaterialTheme.typography.titleMedium)

View File

@@ -154,7 +154,7 @@ fun IconsSettingsScreen() {
Row(
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceContainerLowest,
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.shapes.extraSmall
)
.padding(vertical = 24.dp, horizontal = 8.dp)

View File

@@ -414,7 +414,7 @@ fun ShapePreference(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.extraSmall)
.background(MaterialTheme.colorScheme.surface)
.background(MaterialTheme.colorScheme.surfaceBright)
.clickable(
onClick = { showDialog = true },
)
@@ -733,7 +733,7 @@ private fun ShapePreview(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.extraSmall)
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.horizontalScroll(rememberScrollState())
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,

View File

@@ -232,7 +232,7 @@ private fun TransparencyPreference(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.extraSmall)
.background(MaterialTheme.colorScheme.surface)
.background(MaterialTheme.colorScheme.surfaceBright)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {

View File

@@ -777,7 +777,7 @@ private fun FontPreference(
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.background(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.surfaceBright,
MaterialTheme.shapes.extraSmall
)
) {
@@ -1309,7 +1309,7 @@ private fun TypographyPreview(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.extraSmall)
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.horizontalScroll(rememberScrollState())
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,

View File

@@ -20,21 +20,27 @@ import de.mm20.launcher2.themes.colors.Colors as ThemeColors
import de.mm20.launcher2.themes.colors.get
import de.mm20.launcher2.themes.colors.merge
import de.mm20.launcher2.ui.locals.LocalWallpaperColors
import dynamiccolor.ColorSpec
import hct.Hct
import org.koin.compose.koinInject
import scheme.SchemeContent
import scheme.SchemeExpressive
import scheme.SchemeNeutral
import scheme.SchemeTonalSpot
@Composable
fun lightColorSchemeOf(colors: ThemeColors): ColorScheme {
return colorSchemeOf(colors.lightColorScheme.merge(DefaultLightColorScheme), colors.corePalette)
return colorSchemeOf(false, colors.lightColorScheme.merge(DefaultLightColorScheme), colors.corePalette)
}
@Composable
fun darkColorSchemeOf(colors: ThemeColors): ColorScheme {
return colorSchemeOf(colors.darkColorScheme.merge(DefaultDarkColorScheme), colors.corePalette)
return colorSchemeOf(true, colors.darkColorScheme.merge(DefaultDarkColorScheme), colors.corePalette)
}
@Composable
fun colorSchemeOf(colorScheme: FullColorScheme, corePalette: PartialCorePalette): ColorScheme {
val defaultPalette = systemCorePalette()
fun colorSchemeOf(dark: Boolean, colorScheme: FullColorScheme, corePalette: PartialCorePalette): ColorScheme {
val defaultPalette = systemCorePalette(dark)
return remember(colorScheme, corePalette, defaultPalette) {
val mergedCorePalette = corePalette.merge(defaultPalette)
ColorScheme(
@@ -79,7 +85,7 @@ fun colorSchemeOf(colorScheme: FullColorScheme, corePalette: PartialCorePalette)
}
@Composable
fun systemCorePalette(): CorePalette<Int> {
fun systemCorePalette(dark: Boolean): CorePalette<Int> {
val uiSettings: UiSettings = koinInject()
val compatModeColors by remember {
uiSettings.compatModeColors
@@ -98,14 +104,20 @@ fun systemCorePalette(): CorePalette<Int> {
}
val wallpaperColors = LocalWallpaperColors.current
return remember(wallpaperColors) {
val corePalette = palettes.CorePalette.of(wallpaperColors.primary.toArgb())
val corePalette = SchemeTonalSpot(
Hct.fromInt(
wallpaperColors.primary.toArgb()),
dark,
0.0,
ColorSpec.SpecVersion.SPEC_2025,
)
CorePalette(
primary = corePalette.a1.tone(40),
secondary = corePalette.a2.tone(40),
tertiary = corePalette.a3.tone(40),
neutral = corePalette.n1.tone(40),
neutralVariant = corePalette.n2.tone(40),
error = corePalette.error.tone(40),
primary = corePalette.primaryPaletteKeyColor,
secondary = corePalette.secondaryPaletteKeyColor,
tertiary = corePalette.tertiaryPaletteKeyColor,
neutral = corePalette.neutralPaletteKeyColor,
neutralVariant = corePalette.neutralVariantPaletteKeyColor,
error = corePalette.errorPalette.keyColor.toInt(),
)
}
}

View File

@@ -58,7 +58,7 @@ val DefaultDarkColorScheme = ColorScheme<Color>(
onErrorContainer = ColorRef(CorePaletteColor.Error, 90),
surfaceDim = ColorRef(CorePaletteColor.Neutral, 6),
surface = ColorRef(CorePaletteColor.Neutral, 6),
surfaceBright = ColorRef(CorePaletteColor.Neutral, 24),
surfaceBright = ColorRef(CorePaletteColor.Neutral, 18),
surfaceContainerLowest = ColorRef(CorePaletteColor.Neutral, 4),
surfaceContainerLow = ColorRef(CorePaletteColor.Neutral, 10),
surfaceContainer = ColorRef(CorePaletteColor.Neutral, 12),
@@ -136,7 +136,7 @@ val HighContrastDarkColorScheme = ColorScheme<Color>(
onErrorContainer = ColorRef(CorePaletteColor.Error, 0),
surfaceDim = ColorRef(CorePaletteColor.Neutral, 6),
surface = ColorRef(CorePaletteColor.Neutral, 6),
surfaceBright = ColorRef(CorePaletteColor.Neutral, 24),
surfaceBright = ColorRef(CorePaletteColor.Neutral, 18),
surfaceContainerLowest = ColorRef(CorePaletteColor.Neutral, 4),
surfaceContainerLow = ColorRef(CorePaletteColor.Neutral, 10),
surfaceContainer = ColorRef(CorePaletteColor.Neutral, 12),

View File

@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,91 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This file is automatically generated. Do not modify it.
package blend;
import hct.Cam16;
import hct.Hct;
import utils.ColorUtils;
import utils.MathUtils;
/** Functions for blending in HCT and CAM16. */
public class Blend {
private 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.
*/
public static int harmonize(int designColor, int sourceColor) {
Hct fromHct = Hct.fromInt(designColor);
Hct toHct = Hct.fromInt(sourceColor);
double differenceDegrees = MathUtils.differenceDegrees(fromHct.getHue(), toHct.getHue());
double rotationDegrees = Math.min(differenceDegrees * 0.5, 15.0);
double outputHue =
MathUtils.sanitizeDegreesDouble(
fromHct.getHue()
+ rotationDegrees * MathUtils.rotationDirection(fromHct.getHue(), toHct.getHue()));
return Hct.from(outputHue, fromHct.getChroma(), fromHct.getTone()).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.
*/
public static int hctHue(int from, int to, double amount) {
int ucs = cam16Ucs(from, to, amount);
Cam16 ucsCam = Cam16.fromInt(ucs);
Cam16 fromCam = Cam16.fromInt(from);
Hct blended = Hct.from(ucsCam.getHue(), fromCam.getChroma(), ColorUtils.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.
*/
public static int cam16Ucs(int from, int to, double amount) {
Cam16 fromCam = Cam16.fromInt(from);
Cam16 toCam = Cam16.fromInt(to);
double fromJ = fromCam.getJstar();
double fromA = fromCam.getAstar();
double fromB = fromCam.getBstar();
double toJ = toCam.getJstar();
double toA = toCam.getAstar();
double toB = toCam.getBstar();
double jstar = fromJ + (toJ - fromJ) * amount;
double astar = fromA + (toA - fromA) * amount;
double bstar = fromB + (toB - fromB) * amount;
return Cam16.fromUcs(jstar, astar, bstar).toInt();
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package blend
import hct.Cam16
import hct.Hct
import utils.ColorUtils
import utils.MathUtils
import kotlin.math.min
/** 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.
*/
@JvmStatic
fun harmonize(designColor: Int, sourceColor: Int): Int {
val fromHct = Hct.fromInt(designColor)
val toHct = Hct.fromInt(sourceColor)
val differenceDegrees = MathUtils.differenceDegrees(fromHct.hue, toHct.hue)
val rotationDegrees = min(differenceDegrees * 0.5, 15.0)
val outputHue =
MathUtils.sanitizeDegreesDouble(
fromHct.hue + rotationDegrees * MathUtils.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.
*/
@JvmStatic
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, ColorUtils.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.
*/
@JvmStatic
fun cam16Ucs(from: Int, to: Int, amount: Double): Int {
val fromCam = Cam16.fromInt(from)
val toCam = Cam16.fromInt(to)
val jstar = MathUtils.lerp(fromCam.jstar, toCam.jstar, amount)
val astar = MathUtils.lerp(fromCam.astar, toCam.astar, amount)
val bstar = MathUtils.lerp(fromCam.bstar, toCam.bstar, amount)
return Cam16.fromUcs(jstar, astar, bstar).toInt()
}
}

View File

@@ -0,0 +1,214 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package contrast
import utils.ColorUtils
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). If lighter == 100 and 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
// perceptually 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 perceptual 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.
*/
@JvmStatic
fun ratioOfYs(y1: Double, y2: Double): Double {
val lighter = 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.
*/
@JvmStatic
fun ratioOfTones(t1: Double, t2: Double): Double {
return ratioOfYs(ColorUtils.yFromLstar(t1), ColorUtils.yFromLstar(t2))
}
/**
* Returns T in HCT, L* in L*a*b* >= tone parameter that ensures ratio with input T/L*. Returns
* null if ratio cannot be achieved.
*
* @param tone Tone return value must contrast with.
* @param ratio Desired contrast ratio of return value and tone parameter.
*/
@JvmStatic
fun lighter(tone: Double, ratio: Double): Double? {
if (tone < 0.0 || tone > 100.0) {
return null
}
// Invert the contrast ratio equation to determine lighter Y given a ratio and darker Y.
val darkY = ColorUtils.yFromLstar(tone)
val lightY = ratio * (darkY + 5.0) - 5.0
if (lightY < 0.0 || lightY > 100.0) {
return null
}
val realContrast = ratioOfYs(lightY, darkY)
val delta = abs(realContrast - ratio)
if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) {
return null
}
val returnValue = ColorUtils.lstarFromY(lightY) + LUMINANCE_GAMUT_MAP_TOLERANCE
// NOMUTANTS--important validation step; functions it is calling may change implementation.
return if (returnValue < 0.0 || returnValue > 100.0) {
null
} 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.
*/
@JvmStatic
fun lighterUnsafe(tone: Double, ratio: Double): Double {
return lighter(tone, ratio) ?: 100.0
}
/**
* Returns T in HCT, L* in L*a*b* <= tone parameter that ensures ratio with input T/L*. Returns
* null if ratio cannot be achieved.
*
* @param tone Tone return value must contrast with.
* @param ratio Desired contrast ratio of return value and tone parameter.
*/
@JvmStatic
fun darker(tone: Double, ratio: Double): Double? {
if (tone < 0.0 || tone > 100.0) {
return null
}
// Invert the contrast ratio equation to determine darker Y given a ratio and lighter Y.
val lightY = ColorUtils.yFromLstar(tone)
val darkY = (lightY + 5.0) / ratio - 5.0
if (darkY < 0.0 || darkY > 100.0) {
return null
}
val realContrast = ratioOfYs(lightY, darkY)
val delta = abs(realContrast - ratio)
if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) {
return null
}
// For information on 0.4 constant, see comment in lighter(tone, ratio).
val returnValue = ColorUtils.lstarFromY(darkY) - LUMINANCE_GAMUT_MAP_TOLERANCE
// NOMUTANTS--important validation step; functions it is calling may change implementation.
return if (returnValue < 0.0 || returnValue > 100.0) {
null
} 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.
*/
@JvmStatic
fun darkerUnsafe(tone: Double, ratio: Double): Double {
return darker(tone, ratio) ?: 0.0
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dislike
import hct.Hct
import kotlin.math.round
/**
* 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.
*/
@JvmStatic
fun isDisliked(hct: Hct): Boolean {
val huePasses = round(hct.hue) >= 90.0 && round(hct.hue) <= 111.0
val chromaPasses = round(hct.chroma) > 16.0
val tonePasses = round(hct.tone) < 65.0
return huePasses && chromaPasses && tonePasses
}
/** If color is disliked, lighten it to make it likable. */
@JvmStatic
fun fixIfDisliked(hct: Hct): Hct =
if (isDisliked(hct)) {
Hct.from(hct.hue, hct.chroma, 70.0)
} else {
hct
}
}

View File

@@ -0,0 +1,187 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dynamiccolor
import dynamiccolor.DynamicScheme.Platform
import hct.Hct
import palettes.TonalPalette
/** An interface defining all the necessary methods that could be different between specs. */
interface ColorSpec {
/** All available spec versions. */
enum class SpecVersion {
SPEC_2021,
SPEC_2025,
}
// ////////////////////////////////////////////////////////////////
// Main Palettes //
// ////////////////////////////////////////////////////////////////
val primaryPaletteKeyColor: DynamicColor
val secondaryPaletteKeyColor: DynamicColor
val tertiaryPaletteKeyColor: DynamicColor
val neutralPaletteKeyColor: DynamicColor
val neutralVariantPaletteKeyColor: DynamicColor
val errorPaletteKeyColor: DynamicColor
// ////////////////////////////////////////////////////////////////
// Surfaces [S] //
// ////////////////////////////////////////////////////////////////
val background: DynamicColor
val onBackground: DynamicColor
val surface: DynamicColor
val surfaceDim: DynamicColor
val surfaceBright: DynamicColor
val surfaceContainerLowest: DynamicColor
val surfaceContainerLow: DynamicColor
val surfaceContainer: DynamicColor
val surfaceContainerHigh: DynamicColor
val surfaceContainerHighest: DynamicColor
val onSurface: DynamicColor
val surfaceVariant: DynamicColor
val onSurfaceVariant: DynamicColor
val inverseSurface: DynamicColor
val inverseOnSurface: DynamicColor
val outline: DynamicColor
val outlineVariant: DynamicColor
val shadow: DynamicColor
val scrim: DynamicColor
val surfaceTint: DynamicColor
// ////////////////////////////////////////////////////////////////
// Primaries [P] //
// ////////////////////////////////////////////////////////////////
val primary: DynamicColor
val primaryDim: DynamicColor?
val onPrimary: DynamicColor
val primaryContainer: DynamicColor
val onPrimaryContainer: DynamicColor
val inversePrimary: DynamicColor
// ////////////////////////////////////////////////////////////////
// Secondaries [Q] //
// ////////////////////////////////////////////////////////////////
val secondary: DynamicColor
val secondaryDim: DynamicColor?
val onSecondary: DynamicColor
val secondaryContainer: DynamicColor
val onSecondaryContainer: DynamicColor
// ////////////////////////////////////////////////////////////////
// Tertiaries [T] //
// ////////////////////////////////////////////////////////////////
val tertiary: DynamicColor
val tertiaryDim: DynamicColor?
val onTertiary: DynamicColor
val tertiaryContainer: DynamicColor
val onTertiaryContainer: DynamicColor
// ////////////////////////////////////////////////////////////////
// Errors [E] //
// ////////////////////////////////////////////////////////////////
val error: DynamicColor
val errorDim: DynamicColor?
val onError: DynamicColor
val errorContainer: DynamicColor
val onErrorContainer: DynamicColor
// ////////////////////////////////////////////////////////////////
// Primary Fixed Colors [PF] //
// ////////////////////////////////////////////////////////////////
val primaryFixed: DynamicColor
val primaryFixedDim: DynamicColor
val onPrimaryFixed: DynamicColor
val onPrimaryFixedVariant: DynamicColor
// ////////////////////////////////////////////////////////////////
// Secondary Fixed Colors [QF] //
// ////////////////////////////////////////////////////////////////
val secondaryFixed: DynamicColor
val secondaryFixedDim: DynamicColor
val onSecondaryFixed: DynamicColor
val onSecondaryFixedVariant: DynamicColor
// ////////////////////////////////////////////////////////////////
// Tertiary Fixed Colors [TF] //
// ////////////////////////////////////////////////////////////////
val tertiaryFixed: DynamicColor
val tertiaryFixedDim: DynamicColor
val onTertiaryFixed: DynamicColor
val onTertiaryFixedVariant: DynamicColor
// ////////////////////////////////////////////////////////////////
// Other //
// ////////////////////////////////////////////////////////////////
fun highestSurface(scheme: DynamicScheme): DynamicColor
// ////////////////////////////////////////////////////////////////
// Color value calculations //
// ////////////////////////////////////////////////////////////////
fun getHct(scheme: DynamicScheme, color: DynamicColor): Hct
fun getTone(scheme: DynamicScheme, color: DynamicColor): Double
// ////////////////////////////////////////////////////////////////
// Scheme Palettes //
// ////////////////////////////////////////////////////////////////
fun getPrimaryPalette(
variant: Variant,
sourceColorHct: Hct,
isDark: Boolean,
platform: Platform,
contrastLevel: Double,
): TonalPalette
fun getSecondaryPalette(
variant: Variant,
sourceColorHct: Hct,
isDark: Boolean,
platform: Platform,
contrastLevel: Double,
): TonalPalette
fun getTertiaryPalette(
variant: Variant,
sourceColorHct: Hct,
isDark: Boolean,
platform: Platform,
contrastLevel: Double,
): TonalPalette
fun getNeutralPalette(
variant: Variant,
sourceColorHct: Hct,
isDark: Boolean,
platform: Platform,
contrastLevel: Double,
): TonalPalette
fun getNeutralVariantPalette(
variant: Variant,
sourceColorHct: Hct,
isDark: Boolean,
platform: Platform,
contrastLevel: Double,
): TonalPalette
fun getErrorPalette(
variant: Variant,
sourceColorHct: Hct,
isDark: Boolean,
platform: Platform,
contrastLevel: Double,
): TonalPalette
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dynamiccolor
import dynamiccolor.ColorSpec.SpecVersion
/** A utility class to get the correct color spec for a given spec version. */
object ColorSpecs {
private val SPEC_2021: ColorSpec = ColorSpec2021()
private val SPEC_2025: ColorSpec = ColorSpec2025()
@JvmStatic
fun get(): ColorSpec {
return get(SpecVersion.SPEC_2021)
}
@JvmStatic
fun get(specVersion: SpecVersion): ColorSpec {
return get(specVersion, false)
}
@JvmStatic
fun get(specVersion: SpecVersion, isExtendedFidelity: Boolean): ColorSpec {
return if (specVersion == SpecVersion.SPEC_2025) SPEC_2025 else SPEC_2021
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dynamiccolor
import utils.MathUtils
/**
* A class containing a value that changes with the contrast level.
*
* Usually represents the contrast requirements for a dynamic color on its background. The four
* values correspond to values for contrast levels -1.0, 0.0, 0.5, and 1.0, respectively.
*
* @property low Value for contrast level -1.0
* @property normal Value for contrast level 0.0
* @property medium Value for contrast level 0.5
* @property high Value for contrast level 1.0
*/
class ContrastCurve(val low: Double, val normal: Double, val medium: Double, val high: Double) {
/**
* Returns the value at a given contrast level.
*
* @param contrastLevel The contrast level. 0.0 is the default (normal); -1.0 is the lowest; 1.0
* is the highest.
* @return The value. For contrast ratios, a number between 1.0 and 21.0.
*/
fun get(contrastLevel: Double): Double {
return when {
contrastLevel <= -1.0 -> low
contrastLevel < 0.0 -> MathUtils.lerp(low, normal, contrastLevel + 1.0)
contrastLevel < 0.5 -> MathUtils.lerp(normal, medium, contrastLevel / 0.5)
contrastLevel < 1.0 -> MathUtils.lerp(medium, high, (contrastLevel - 0.5) / 0.5)
else -> high
}
}
}

View File

@@ -0,0 +1,287 @@
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dynamiccolor
import contrast.Contrast
import dynamiccolor.ColorSpec.SpecVersion
import hct.Hct
import palettes.TonalPalette
import kotlin.math.abs
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.
*
* 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 accessibility guidance. That does not mean it's the desired
* approach for _every_ design system, and every color pairing, always, in every case.
*
* 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 is usually unnecessary.
*
* @param name The name of the dynamic color.
* @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.
* @param isBackground Whether this dynamic color is a background, with some other color as the
* foreground.
* @param chromaMultiplier Function that provides a chroma multiplier, given a DynamicScheme.
* @param background Function that provides a background color, given a DynamicScheme.
* @param tone Function that provides a tone, given a DynamicScheme.
* @param secondBackground Function that provides a second background color, given a DynamicScheme.
* @param contrastCurve Function that provides a contrast curve, given a DynamicScheme.
* @param toneDeltaPair Function that provides a tone delta pair, given a DynamicScheme.
* @param opacity Function that provides an opacity percentage, given a DynamicScheme.
*/
data class DynamicColor(
val name: String,
val palette: (DynamicScheme) -> TonalPalette,
val isBackground: Boolean = false,
val chromaMultiplier: ((DynamicScheme) -> Double)? = null,
val background: ((DynamicScheme) -> DynamicColor?)? = null,
val tone: (DynamicScheme) -> Double = getInitialToneFromBackground(background),
val secondBackground: ((DynamicScheme) -> DynamicColor?)? = null,
val contrastCurve: ((DynamicScheme) -> ContrastCurve?)? = null,
val toneDeltaPair: ((DynamicScheme) -> ToneDeltaPair?)? = null,
val opacity: ((DynamicScheme) -> Double?)? = null,
) {
init {
if (background == null && secondBackground != null) {
throw IllegalArgumentException(
"Color $name has secondBackground defined, but background is not defined."
)
}
if (background == null && contrastCurve != null) {
throw IllegalArgumentException(
"Color $name has contrastCurve defined, but background is not defined."
)
}
if (background != null && contrastCurve == null) {
throw IllegalArgumentException(
"Color $name has background defined, but contrastCurve is not defined."
)
}
}
private val hctCache = mutableMapOf<DynamicScheme, Hct>()
/**
* Returns an ARGB integer (i.e. a hex code).
*
* @param scheme Defines the conditions of the user interface, for example, whether or not it is
* dark mode or light mode, and what the desired contrast level is.
*/
fun getArgb(scheme: DynamicScheme): Int {
val argb = getHct(scheme).toInt()
val opacityPercentage = opacity?.invoke(scheme)
return if (opacityPercentage == null) {
argb
} else {
val alpha = (opacityPercentage * 255).roundToInt().coerceIn(0, 255)
(argb and 0x00ffffff) or (alpha shl 24)
}
}
/**
* Returns an HCT object.
*
* @param scheme Defines the conditions of the user interface, for example, whether or not it is
* dark mode or light mode, and what the desired contrast level is.
*/
fun getHct(scheme: DynamicScheme): Hct {
val cachedAnswer = hctCache[scheme]
if (cachedAnswer != null) {
return cachedAnswer
}
val answer = ColorSpecs.get(scheme.specVersion).getHct(scheme, this)
// 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 {
return ColorSpecs.get(scheme.specVersion).getTone(scheme, this)
}
companion object {
/**
* Create a DynamicColor from a hex code.
*
* Result has no background; thus no support for increasing/decreasing contrast for a11y.
*
* @param name The name of the dynamic color.
* @param argb The source color from which to extract the hue and chroma.
*/
@JvmStatic
fun fromArgb(name: String, argb: Int): DynamicColor {
val hct = Hct.fromInt(argb)
val palette = TonalPalette.fromInt(argb)
return DynamicColor(name = name, palette = { palette }, tone = { hct.tone })
}
/**
* Given a background tone, find a foreground tone, while ensuring they reach a contrast ratio
* that is as close to ratio as possible.
*/
@JvmStatic
fun foregroundTone(bgTone: Double, ratio: Double): Double {
val lighterTone = Contrast.lighterUnsafe(bgTone, ratio)
val darkerTone = Contrast.darkerUnsafe(bgTone, ratio)
val lighterRatio = Contrast.ratioOfTones(lighterTone, bgTone)
val darkerRatio = Contrast.ratioOfTones(darkerTone, bgTone)
val preferLighter = tonePrefersLightForeground(bgTone)
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
return if (lighterRatio >= ratio || lighterRatio >= darkerRatio || negligibleDifference) {
lighterTone
} else {
darkerTone
}
} else {
return 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.
*/
@JvmStatic
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.
*
* Since `tertiaryContainer` in dark monochrome scheme requires a tone of 60, it should not be
* adjusted. Therefore, 60 is excluded here.
*/
@JvmStatic
fun tonePrefersLightForeground(tone: Double): Boolean {
return tone.roundToInt() < 60
}
/** Tones less than ~T50 always permit white at 4.5 contrast. */
@JvmStatic
fun toneAllowsLightForeground(tone: Double): Boolean {
return tone.roundToInt() <= 49
}
@JvmStatic
fun getInitialToneFromBackground(
background: ((DynamicScheme) -> DynamicColor?)?
): (DynamicScheme) -> Double {
if (background == null) {
return { 50.0 }
}
return { scheme -> background(scheme)?.getTone(scheme) ?: 50.0 }
}
}
}
fun DynamicColor.extendSpecVersion(
specVersion: SpecVersion,
extendedColor: DynamicColor,
): DynamicColor {
validateExtendedColor(specVersion, extendedColor)
return copy(
palette = { scheme ->
(if (scheme.specVersion == specVersion) extendedColor.palette else this.palette).invoke(
scheme
)
},
tone = { scheme ->
(if (scheme.specVersion == specVersion) extendedColor.tone else this.tone).invoke(scheme)
},
chromaMultiplier = { scheme ->
(if (scheme.specVersion == specVersion) {
extendedColor.chromaMultiplier
} else {
this.chromaMultiplier
})
?.invoke(scheme) ?: 1.0
},
background = { scheme ->
(if (scheme.specVersion == specVersion) extendedColor.background else this.background)
?.invoke(scheme)
},
secondBackground = { scheme ->
(if (scheme.specVersion == specVersion) {
extendedColor.secondBackground
} else {
this.secondBackground
})
?.invoke(scheme)
},
contrastCurve = { scheme ->
(if (scheme.specVersion == specVersion) extendedColor.contrastCurve else this.contrastCurve)
?.invoke(scheme)
},
toneDeltaPair = { scheme ->
(if (scheme.specVersion == specVersion) extendedColor.toneDeltaPair else this.toneDeltaPair)
?.invoke(scheme)
},
opacity = { scheme ->
(if (scheme.specVersion == specVersion) extendedColor.opacity else this.opacity)?.invoke(
scheme
)
},
)
}
private fun DynamicColor.validateExtendedColor(
specVersion: SpecVersion,
extendedColor: DynamicColor,
) {
require(this.name == extendedColor.name) {
"Attempting to extend color $name with color ${extendedColor.name} of different name for spec version $specVersion."
}
require(this.isBackground == extendedColor.isBackground) {
"Attempting to extend color $name as a ${if (isBackground) "background" else "foreground"} with color ${extendedColor.name} as a ${if (extendedColor.isBackground) "background" else "foreground"} for spec version $specVersion."
}
}

View File

@@ -0,0 +1,373 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dynamiccolor
import dynamiccolor.ColorSpec.SpecVersion
import hct.Hct
import palettes.TonalPalette
import utils.MathUtils
import java.text.DecimalFormat
import java.util.Locale
import kotlin.math.min
/**
* 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(
/** The source color of the scheme in HCT format. */
val sourceColorHct: Hct,
/** The variant of the scheme. */
val variant: Variant,
/** Whether or not the scheme is dark mode. */
val isDark: Boolean,
/**
* Value from -1 to 1. -1 represents minimum contrast. 0 represents standard (i.e. the design as
* spec'd), and 1 represents maximum contrast.
*/
val contrastLevel: Double,
/** The platform on which this scheme is intended to be used. */
val platform: Platform = DEFAULT_PLATFORM,
/** The spec version of the scheme. */
specVersion: SpecVersion = DEFAULT_SPEC_VERSION,
val primaryPalette: TonalPalette,
val secondaryPalette: TonalPalette,
val tertiaryPalette: TonalPalette,
val neutralPalette: TonalPalette,
val neutralVariantPalette: TonalPalette,
val errorPalette: TonalPalette,
) {
/** The spec version of the scheme. */
val specVersion: SpecVersion
init {
this.specVersion = maybeFallbackSpecVersion(specVersion, variant)
}
/** The source color of the scheme in ARGB format. */
val sourceColorArgb: Int = sourceColorHct.toInt()
/** The platform on which this scheme is intended to be used. */
enum class Platform {
PHONE,
WATCH,
}
fun getHct(dynamicColor: DynamicColor): Hct {
return dynamicColor.getHct(this)
}
fun getArgb(dynamicColor: DynamicColor): Int {
return dynamicColor.getArgb(this)
}
override fun toString(): String {
return "Scheme: variant=${variant.name}, mode=${if (isDark) "dark" else "light"}, platform=${platform.name.lowercase(
Locale.ENGLISH
)}, contrastLevel=${DecimalFormat("0.0").format(contrastLevel)}, seed=$sourceColorHct, specVersion=$specVersion"
}
private val dynamicColors = MaterialDynamicColors()
val primaryPaletteKeyColor: Int
get() = getArgb(dynamicColors.primaryPaletteKeyColor)
val secondaryPaletteKeyColor: Int
get() = getArgb(dynamicColors.secondaryPaletteKeyColor)
val tertiaryPaletteKeyColor: Int
get() = getArgb(dynamicColors.tertiaryPaletteKeyColor)
val neutralPaletteKeyColor: Int
get() = getArgb(dynamicColors.neutralPaletteKeyColor)
val neutralVariantPaletteKeyColor: Int
get() = getArgb(dynamicColors.neutralVariantPaletteKeyColor)
val background: Int
get() = getArgb(dynamicColors.background)
val onBackground: Int
get() = getArgb(dynamicColors.onBackground)
val surface: Int
get() = getArgb(dynamicColors.surface)
val surfaceDim: Int
get() = getArgb(dynamicColors.surfaceDim)
val surfaceBright: Int
get() = getArgb(dynamicColors.surfaceBright)
val surfaceContainerLowest: Int
get() = getArgb(dynamicColors.surfaceContainerLowest)
val surfaceContainerLow: Int
get() = getArgb(dynamicColors.surfaceContainerLow)
val surfaceContainer: Int
get() = getArgb(dynamicColors.surfaceContainer)
val surfaceContainerHigh: Int
get() = getArgb(dynamicColors.surfaceContainerHigh)
val surfaceContainerHighest: Int
get() = getArgb(dynamicColors.surfaceContainerHighest)
val onSurface: Int
get() = getArgb(dynamicColors.onSurface)
val surfaceVariant: Int
get() = getArgb(dynamicColors.surfaceVariant)
val onSurfaceVariant: Int
get() = getArgb(dynamicColors.onSurfaceVariant)
val inverseSurface: Int
get() = getArgb(dynamicColors.inverseSurface)
val inverseOnSurface: Int
get() = getArgb(dynamicColors.inverseOnSurface)
val outline: Int
get() = getArgb(dynamicColors.outline)
val outlineVariant: Int
get() = getArgb(dynamicColors.outlineVariant)
val shadow: Int
get() = getArgb(dynamicColors.shadow)
val scrim: Int
get() = getArgb(dynamicColors.scrim)
val surfaceTint: Int
get() = getArgb(dynamicColors.surfaceTint)
val primary: Int
get() = getArgb(dynamicColors.primary)
val onPrimary: Int
get() = getArgb(dynamicColors.onPrimary)
val primaryContainer: Int
get() = getArgb(dynamicColors.primaryContainer)
val onPrimaryContainer: Int
get() = getArgb(dynamicColors.onPrimaryContainer)
val inversePrimary: Int
get() = getArgb(dynamicColors.inversePrimary)
val secondary: Int
get() = getArgb(dynamicColors.secondary)
val onSecondary: Int
get() = getArgb(dynamicColors.onSecondary)
val secondaryContainer: Int
get() = getArgb(dynamicColors.secondaryContainer)
val onSecondaryContainer: Int
get() = getArgb(dynamicColors.onSecondaryContainer)
val tertiary: Int
get() = getArgb(dynamicColors.tertiary)
val onTertiary: Int
get() = getArgb(dynamicColors.onTertiary)
val tertiaryContainer: Int
get() = getArgb(dynamicColors.tertiaryContainer)
val onTertiaryContainer: Int
get() = getArgb(dynamicColors.onTertiaryContainer)
val error: Int
get() = getArgb(dynamicColors.error)
val onError: Int
get() = getArgb(dynamicColors.onError)
val errorContainer: Int
get() = getArgb(dynamicColors.errorContainer)
val onErrorContainer: Int
get() = getArgb(dynamicColors.onErrorContainer)
val primaryFixed: Int
get() = getArgb(dynamicColors.primaryFixed)
val primaryFixedDim: Int
get() = getArgb(dynamicColors.primaryFixedDim)
val onPrimaryFixed: Int
get() = getArgb(dynamicColors.onPrimaryFixed)
val onPrimaryFixedVariant: Int
get() = getArgb(dynamicColors.onPrimaryFixedVariant)
val secondaryFixed: Int
get() = getArgb(dynamicColors.secondaryFixed)
val secondaryFixedDim: Int
get() = getArgb(dynamicColors.secondaryFixedDim)
val onSecondaryFixed: Int
get() = getArgb(dynamicColors.onSecondaryFixed)
val onSecondaryFixedVariant: Int
get() = getArgb(dynamicColors.onSecondaryFixedVariant)
val tertiaryFixed: Int
get() = getArgb(dynamicColors.tertiaryFixed)
val tertiaryFixedDim: Int
get() = getArgb(dynamicColors.tertiaryFixedDim)
val onTertiaryFixed: Int
get() = getArgb(dynamicColors.onTertiaryFixed)
val onTertiaryFixedVariant: Int
get() = getArgb(dynamicColors.onTertiaryFixedVariant)
companion object {
val DEFAULT_SPEC_VERSION = SpecVersion.SPEC_2021
val DEFAULT_PLATFORM = Platform.PHONE
@JvmStatic
fun from(other: DynamicScheme, isDark: Boolean): DynamicScheme {
return from(other, isDark, other.contrastLevel)
}
@JvmStatic
fun from(other: DynamicScheme, isDark: Boolean, contrastLevel: Double): DynamicScheme {
return DynamicScheme(
other.sourceColorHct,
other.variant,
isDark,
contrastLevel,
other.platform,
other.specVersion,
other.primaryPalette,
other.secondaryPalette,
other.tertiaryPalette,
other.neutralPalette,
other.neutralVariantPalette,
other.errorPalette,
)
}
/**
* Returns a new hue based on a piecewise function and input color hue.
*
* For example, for the following function:
* ```
* result = 26, if 0 <= hue < 101;
* result = 39, if 101 <= hue < 210;
* result = 28, if 210 <= hue < 360.
* ```
*
* call the function as:
* ```
* double[] hueBreakpoints = {0, 101, 210, 360};
* double[] hues = {26, 39, 28};
* double result = scheme.piecewise(sourceColor, hueBreakpoints, hues);
* ```
*
* @param sourceColorHct The input value.
* @param hueBreakpoints The breakpoints, in sorted order. No default lower or upper bounds are
* assumed.
* @param hues The hues that should be applied when source color's hue is >= the same index in
* hueBreakpoints array, and < the hue at the next index in hueBreakpoints array. Otherwise,
* the source color's hue is returned.
*/
@JvmStatic
fun getPiecewiseValue(
sourceColorHct: Hct,
hueBreakpoints: DoubleArray,
hues: DoubleArray,
): Double {
val size = min(hueBreakpoints.size - 1, hues.size)
val sourceHue = sourceColorHct.hue
for (i in 0 until size) {
if (sourceHue >= hueBreakpoints[i] && sourceHue < hueBreakpoints[i + 1]) {
return MathUtils.sanitizeDegreesDouble(hues[i])
}
}
// No condition matched, return the source value.
return sourceHue
}
/**
* Returns a shifted hue based on a piecewise function and input color hue.
*
* For example, for the following function:
* ```
* result = hue + 26, if 0 <= hue < 101;
* result = hue - 39, if 101 <= hue < 210;
* result = hue + 28, if 210 <= hue < 360.
* ```
*
* call the function as:
* ```
* double[] hueBreakpoints = {0, 101, 210, 360};
* double[] rotations = {26, -39, 28};
* double result = scheme.getRotatedHue(sourceColor, hueBreakpoints, rotations);
* ```
*
* @param sourceColorHct the source color of the theme, in HCT.
* @param hueBreakpoints The "breakpoints", i.e. the hues at which a rotation should be apply.
* No default lower or upper bounds are assumed.
* @param rotations The rotation that should be applied when source color's hue is >= the same
* index in hues array, and < the hue at the next index in hues array. Otherwise, the source
* color's hue is returned.
*/
@JvmStatic
fun getRotatedHue(
sourceColorHct: Hct,
hueBreakpoints: DoubleArray,
rotations: DoubleArray,
): Double {
var rotation = getPiecewiseValue(sourceColorHct, hueBreakpoints, rotations)
if (min(hueBreakpoints.size - 1, rotations.size) <= 0) {
// No condition matched, return the source hue.
rotation = 0.0
}
return MathUtils.sanitizeDegreesDouble(sourceColorHct.hue + rotation)
}
/**
* Returns the spec version to use for the given variant. If the variant is not supported by the
* given spec version, the fallback spec version is returned.
*/
private fun maybeFallbackSpecVersion(specVersion: SpecVersion, variant: Variant): SpecVersion {
return when (variant) {
Variant.EXPRESSIVE,
Variant.VIBRANT,
Variant.TONAL_SPOT,
Variant.NEUTRAL -> specVersion
Variant.MONOCHROME,
Variant.FIDELITY,
Variant.CONTENT,
Variant.RAINBOW,
Variant.FRUIT_SALAD -> SpecVersion.SPEC_2021
}
}
}
}

View File

@@ -0,0 +1,295 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dynamiccolor
/** Named colors, otherwise known as tokens, or roles, in the Material Design system. */
class MaterialDynamicColors {
private val colorSpec: ColorSpec = ColorSpec2025()
fun highestSurface(scheme: DynamicScheme): DynamicColor = colorSpec.highestSurface(scheme)
// ////////////////////////////////////////////////////////////////
// Main Palettes //
// ////////////////////////////////////////////////////////////////
val primaryPaletteKeyColor: DynamicColor
get() = colorSpec.primaryPaletteKeyColor
val secondaryPaletteKeyColor: DynamicColor
get() = colorSpec.secondaryPaletteKeyColor
val tertiaryPaletteKeyColor: DynamicColor
get() = colorSpec.tertiaryPaletteKeyColor
val neutralPaletteKeyColor: DynamicColor
get() = colorSpec.neutralPaletteKeyColor
val neutralVariantPaletteKeyColor: DynamicColor
get() = colorSpec.neutralVariantPaletteKeyColor
val errorPaletteKeyColor: DynamicColor
get() = colorSpec.errorPaletteKeyColor
// ////////////////////////////////////////////////////////////////
// Surfaces [S] //
// ////////////////////////////////////////////////////////////////
val background: DynamicColor
get() = colorSpec.background
val onBackground: DynamicColor
get() = colorSpec.onBackground
val surface: DynamicColor
get() = colorSpec.surface
val surfaceDim: DynamicColor
get() = colorSpec.surfaceDim
val surfaceBright: DynamicColor
get() = colorSpec.surfaceBright
val surfaceContainerLowest: DynamicColor
get() = colorSpec.surfaceContainerLowest
val surfaceContainerLow: DynamicColor
get() = colorSpec.surfaceContainerLow
val surfaceContainer: DynamicColor
get() = colorSpec.surfaceContainer
val surfaceContainerHigh: DynamicColor
get() = colorSpec.surfaceContainerHigh
val surfaceContainerHighest: DynamicColor
get() = colorSpec.surfaceContainerHighest
val onSurface: DynamicColor
get() = colorSpec.onSurface
val surfaceVariant: DynamicColor
get() = colorSpec.surfaceVariant
val onSurfaceVariant: DynamicColor
get() = colorSpec.onSurfaceVariant
val inverseSurface: DynamicColor
get() = colorSpec.inverseSurface
val inverseOnSurface: DynamicColor
get() = colorSpec.inverseOnSurface
val outline: DynamicColor
get() = colorSpec.outline
val outlineVariant: DynamicColor
get() = colorSpec.outlineVariant
val shadow: DynamicColor
get() = colorSpec.shadow
val scrim: DynamicColor
get() = colorSpec.scrim
val surfaceTint: DynamicColor
get() = colorSpec.surfaceTint
// ////////////////////////////////////////////////////////////////
// Primaries [P] //
// ////////////////////////////////////////////////////////////////
val primary: DynamicColor
get() = colorSpec.primary
val primaryDim: DynamicColor?
get() = colorSpec.primaryDim
val onPrimary: DynamicColor
get() = colorSpec.onPrimary
val primaryContainer: DynamicColor
get() = colorSpec.primaryContainer
val onPrimaryContainer: DynamicColor
get() = colorSpec.onPrimaryContainer
val inversePrimary: DynamicColor
get() = colorSpec.inversePrimary
// ///////////////////////////////////////////////////////////////
// Primary Fixed Colors [PF] //
// ///////////////////////////////////////////////////////////////
val primaryFixed: DynamicColor
get() = colorSpec.primaryFixed
val primaryFixedDim: DynamicColor
get() = colorSpec.primaryFixedDim
val onPrimaryFixed: DynamicColor
get() = colorSpec.onPrimaryFixed
val onPrimaryFixedVariant: DynamicColor
get() = colorSpec.onPrimaryFixedVariant
// ////////////////////////////////////////////////////////////////
// Secondaries [Q] //
// ////////////////////////////////////////////////////////////////
val secondary: DynamicColor
get() = colorSpec.secondary
val secondaryDim: DynamicColor?
get() = colorSpec.secondaryDim
val onSecondary: DynamicColor
get() = colorSpec.onSecondary
val secondaryContainer: DynamicColor
get() = colorSpec.secondaryContainer
val onSecondaryContainer: DynamicColor
get() = colorSpec.onSecondaryContainer
// ///////////////////////////////////////////////////////////////
// Secondary Fixed Colors [QF] //
// ///////////////////////////////////////////////////////////////
val secondaryFixed: DynamicColor
get() = colorSpec.secondaryFixed
val secondaryFixedDim: DynamicColor
get() = colorSpec.secondaryFixedDim
val onSecondaryFixed: DynamicColor
get() = colorSpec.onSecondaryFixed
val onSecondaryFixedVariant: DynamicColor
get() = colorSpec.onSecondaryFixedVariant
// ////////////////////////////////////////////////////////////////
// Tertiaries [T] //
// ////////////////////////////////////////////////////////////////
val tertiary: DynamicColor
get() = colorSpec.tertiary
val tertiaryDim: DynamicColor?
get() = colorSpec.tertiaryDim
val onTertiary: DynamicColor
get() = colorSpec.onTertiary
val tertiaryContainer: DynamicColor
get() = colorSpec.tertiaryContainer
val onTertiaryContainer: DynamicColor
get() = colorSpec.onTertiaryContainer
// ///////////////////////////////////////////////////////////////
// Tertiary Fixed Colors [TF] //
// ///////////////////////////////////////////////////////////////
val tertiaryFixed: DynamicColor
get() = colorSpec.tertiaryFixed
val tertiaryFixedDim: DynamicColor
get() = colorSpec.tertiaryFixedDim
val onTertiaryFixed: DynamicColor
get() = colorSpec.onTertiaryFixed
val onTertiaryFixedVariant: DynamicColor
get() = colorSpec.onTertiaryFixedVariant
// ////////////////////////////////////////////////////////////////
// Errors [E] //
// ////////////////////////////////////////////////////////////////
val error: DynamicColor
get() = colorSpec.error
val errorDim: DynamicColor?
get() = colorSpec.errorDim
val onError: DynamicColor
get() = colorSpec.onError
val errorContainer: DynamicColor
get() = colorSpec.errorContainer
val onErrorContainer: DynamicColor
get() = colorSpec.onErrorContainer
// ////////////////////////////////////////////////////////////////
// All Colors //
// ////////////////////////////////////////////////////////////////
/** All dynamic colors in Material Design system. */
val allDynamicColors: List<() -> DynamicColor?> by lazy {
listOf(
this::primaryPaletteKeyColor,
this::secondaryPaletteKeyColor,
this::tertiaryPaletteKeyColor,
this::neutralPaletteKeyColor,
this::neutralVariantPaletteKeyColor,
this::errorPaletteKeyColor,
this::background,
this::onBackground,
this::surface,
this::surfaceDim,
this::surfaceBright,
this::surfaceContainerLowest,
this::surfaceContainerLow,
this::surfaceContainer,
this::surfaceContainerHigh,
this::surfaceContainerHighest,
this::onSurface,
this::surfaceVariant,
this::onSurfaceVariant,
this::outline,
this::outlineVariant,
this::inverseSurface,
this::inverseOnSurface,
this::shadow,
this::scrim,
this::surfaceTint,
this::primary,
this::primaryDim,
this::onPrimary,
this::primaryContainer,
this::onPrimaryContainer,
this::primaryFixed,
this::primaryFixedDim,
this::onPrimaryFixed,
this::onPrimaryFixedVariant,
this::inversePrimary,
this::secondary,
this::secondaryDim,
this::onSecondary,
this::secondaryContainer,
this::onSecondaryContainer,
this::secondaryFixed,
this::secondaryFixedDim,
this::onSecondaryFixed,
this::onSecondaryFixedVariant,
this::tertiary,
this::tertiaryDim,
this::onTertiary,
this::tertiaryContainer,
this::onTertiaryContainer,
this::tertiaryFixed,
this::tertiaryFixedDim,
this::onTertiaryFixed,
this::onTertiaryFixedVariant,
this::error,
this::errorDim,
this::onError,
this::errorContainer,
this::onErrorContainer,
)
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dynamiccolor
/**
* Documents a constraint between two DynamicColors, in which their tones must have a certain
* distance from each other.
*
* The polarity is an adjective that describes "A", compared to "B". For instance, ToneDeltaPair(A,
* B, 15, 'darker') states that A's tone should be at least 15 darker than B's.
*
* Prefer a DynamicColor with a background, this is for special cases when designers want tonal
* distance, literally contrast, between two colors that don't have a background / foreground
* relationship or a contrast guarantee.
*
* @param roleA The first role in a pair.
* @param roleB The second role in a pair.
* @param delta Required difference between tones. Absolute value, negative values have undefined
* behavior.
* @param polarity The relative relation between tones of roleA and roleB, as described above.
* @param stayTogether Whether these two roles should stay on the same side of the "awkward zone"
* (T50-59). This is necessary for certain cases where one role has two backgrounds.
* @param constraint How to fulfill the tone delta pair constraint.
*/
data class ToneDeltaPair(
val roleA: DynamicColor,
val roleB: DynamicColor,
val delta: Double,
val polarity: TonePolarity,
val stayTogether: Boolean = true,
val constraint: DeltaConstraint = DeltaConstraint.EXACT,
) {
/**
* Describes how to fulfill a tone delta pair constraint.
*
* Determines if the delta is a minimum, maximum, or exact tonal distance that must be maintained.
*/
enum class DeltaConstraint {
// The tone of roleA must be an exact delta away from the tone of roleB.
EXACT,
// The tonal distance of roleA and roleB must be at most delta.
NEARER,
// The tonal distance of roleA and roleB must be at least delta.
FARTHER,
}
/**
* Describes the relationship in lightness between two colors.
*
* 'relative_darker' and 'relative_lighter' describes the tone adjustment relative to the surface
* color trend (white in light mode; black in dark mode). For instance, ToneDeltaPair(A, B, 10,
* 'relative_lighter', 'farther') states that A should be at least 10 lighter than B in light
* mode, and at least 10 darker than B in dark mode.
*/
enum class TonePolarity {
// The tone of roleA is always darker than the tone of roleB.
DARKER,
// The tone of roleA is always lighter than the tone of roleB.
LIGHTER,
// The tone of roleA is darker than the tone of roleB in light mode, and lighter than the tone
// ofroleB in dark mode.
RELATIVE_DARKER,
// The tone of roleA is lighter than the tone of roleB in light mode, and darker than the tone
// ofroleB in dark mode.
RELATIVE_LIGHTER,
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dynamiccolor
/** Themes for Dynamic Color. */
enum class Variant {
MONOCHROME,
NEUTRAL,
TONAL_SPOT,
VIBRANT,
EXPRESSIVE,
FIDELITY,
CONTENT,
RAINBOW,
FRUIT_SALAD,
}

View File

@@ -1,439 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package hct;
import static java.lang.Math.max;
import utils.ColorUtils;
/**
* CAM16, a color appearance model. Colors are not just defined by their hex code, but rather, a hex
* code and viewing conditions.
*
* <p>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.
*
* <p>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.
*
* <p>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)
*/
public final class Cam16 {
// Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16.
static final double[][] XYZ_TO_CAM16RGB = {
{0.401288, 0.650173, -0.051461},
{-0.250268, 1.204414, 0.045854},
{-0.002079, 0.048952, 0.953127}
};
// Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates.
static final double[][] CAM16RGB_TO_XYZ = {
{1.8620678, -1.0112547, 0.14918678},
{0.38752654, 0.62144744, -0.00897398},
{-0.01584150, -0.03412294, 1.0499644}
};
// CAM16 color dimensions, see getters for documentation.
private final double hue;
private final double chroma;
private final double j;
private final double q;
private final double m;
private final double s;
// Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*.
private final double jstar;
private final double astar;
private final double bstar;
// Avoid allocations during conversion by pre-allocating an array.
private final double[] tempArray = new double[] {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.
*/
double distance(Cam16 other) {
double dJ = getJstar() - other.getJstar();
double dA = getAstar() - other.getAstar();
double dB = getBstar() - other.getBstar();
double dEPrime = Math.sqrt(dJ * dJ + dA * dA + dB * dB);
double dE = 1.41 * Math.pow(dEPrime, 0.63);
return dE;
}
/** Hue in CAM16 */
public double getHue() {
return hue;
}
/** Chroma in CAM16 */
public double getChroma() {
return chroma;
}
/** Lightness in CAM16 */
public double getJ() {
return j;
}
/**
* Brightness in CAM16.
*
* <p>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.
*/
public double getQ() {
return q;
}
/**
* Colorfulness in CAM16.
*
* <p>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.
*/
public double getM() {
return m;
}
/**
* Saturation in CAM16.
*
* <p>Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness
* relative to the color's own brightness, where chroma is colorfulness relative to white.
*/
public double getS() {
return s;
}
/** Lightness coordinate in CAM16-UCS */
public double getJstar() {
return jstar;
}
/** a* coordinate in CAM16-UCS */
public double getAstar() {
return astar;
}
/** b* coordinate in CAM16-UCS */
public double getBstar() {
return bstar;
}
/**
* 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 Cam16(
double hue,
double chroma,
double j,
double q,
double m,
double s,
double jstar,
double astar,
double bstar) {
this.hue = hue;
this.chroma = chroma;
this.j = j;
this.q = q;
this.m = m;
this.s = s;
this.jstar = jstar;
this.astar = astar;
this.bstar = bstar;
}
/**
* Create a CAM16 color from a color, assuming the color was viewed in default viewing conditions.
*
* @param argb ARGB representation of a color.
*/
public static Cam16 fromInt(int argb) {
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.
@SuppressWarnings("FloatingPointLiteralPrecision")
static Cam16 fromIntInViewingConditions(int argb, ViewingConditions viewingConditions) {
// Transform ARGB int to XYZ
int red = (argb & 0x00ff0000) >> 16;
int green = (argb & 0x0000ff00) >> 8;
int blue = (argb & 0x000000ff);
double redL = ColorUtils.linearized(red);
double greenL = ColorUtils.linearized(green);
double blueL = ColorUtils.linearized(blue);
double x = 0.41233895 * redL + 0.35762064 * greenL + 0.18051042 * blueL;
double y = 0.2126 * redL + 0.7152 * greenL + 0.0722 * blueL;
double z = 0.01932141 * redL + 0.11916382 * greenL + 0.95034478 * blueL;
return fromXyzInViewingConditions(x, y, z, viewingConditions);
}
static Cam16 fromXyzInViewingConditions(
double x, double y, double z, ViewingConditions viewingConditions) {
// Transform XYZ to 'cone'/'rgb' responses
double[][] matrix = XYZ_TO_CAM16RGB;
double rT = (x * matrix[0][0]) + (y * matrix[0][1]) + (z * matrix[0][2]);
double gT = (x * matrix[1][0]) + (y * matrix[1][1]) + (z * matrix[1][2]);
double bT = (x * matrix[2][0]) + (y * matrix[2][1]) + (z * matrix[2][2]);
// Discount illuminant
double rD = viewingConditions.getRgbD()[0] * rT;
double gD = viewingConditions.getRgbD()[1] * gT;
double bD = viewingConditions.getRgbD()[2] * bT;
// Chromatic adaptation
double rAF = Math.pow(viewingConditions.getFl() * Math.abs(rD) / 100.0, 0.42);
double gAF = Math.pow(viewingConditions.getFl() * Math.abs(gD) / 100.0, 0.42);
double bAF = Math.pow(viewingConditions.getFl() * Math.abs(bD) / 100.0, 0.42);
double rA = Math.signum(rD) * 400.0 * rAF / (rAF + 27.13);
double gA = Math.signum(gD) * 400.0 * gAF / (gAF + 27.13);
double bA = Math.signum(bD) * 400.0 * bAF / (bAF + 27.13);
// redness-greenness
double a = (11.0 * rA + -12.0 * gA + bA) / 11.0;
// yellowness-blueness
double b = (rA + gA - 2.0 * bA) / 9.0;
// auxiliary components
double u = (20.0 * rA + 20.0 * gA + 21.0 * bA) / 20.0;
double p2 = (40.0 * rA + 20.0 * gA + bA) / 20.0;
// hue
double atan2 = Math.atan2(b, a);
double atanDegrees = Math.toDegrees(atan2);
double hue =
atanDegrees < 0
? atanDegrees + 360.0
: atanDegrees >= 360 ? atanDegrees - 360.0 : atanDegrees;
double hueRadians = Math.toRadians(hue);
// achromatic response to color
double ac = p2 * viewingConditions.getNbb();
// CAM16 lightness and brightness
double j =
100.0
* Math.pow(
ac / viewingConditions.getAw(),
viewingConditions.getC() * viewingConditions.getZ());
double q =
4.0
/ viewingConditions.getC()
* Math.sqrt(j / 100.0)
* (viewingConditions.getAw() + 4.0)
* viewingConditions.getFlRoot();
// CAM16 chroma, colorfulness, and saturation.
double huePrime = (hue < 20.14) ? hue + 360 : hue;
double eHue = 0.25 * (Math.cos(Math.toRadians(huePrime) + 2.0) + 3.8);
double p1 = 50000.0 / 13.0 * eHue * viewingConditions.getNc() * viewingConditions.getNcb();
double t = p1 * Math.hypot(a, b) / (u + 0.305);
double alpha =
Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73) * Math.pow(t, 0.9);
// CAM16 chroma, colorfulness, saturation
double c = alpha * Math.sqrt(j / 100.0);
double m = c * viewingConditions.getFlRoot();
double s =
50.0 * Math.sqrt((alpha * viewingConditions.getC()) / (viewingConditions.getAw() + 4.0));
// CAM16-UCS components
double jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j);
double mstar = 1.0 / 0.0228 * Math.log1p(0.0228 * m);
double astar = mstar * Math.cos(hueRadians);
double bstar = mstar * Math.sin(hueRadians);
return new Cam16(hue, c, j, q, m, s, jstar, astar, bstar);
}
/**
* @param j CAM16 lightness
* @param c CAM16 chroma
* @param h CAM16 hue
*/
static Cam16 fromJch(double j, double c, double h) {
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 static Cam16 fromJchInViewingConditions(
double j, double c, double h, ViewingConditions viewingConditions) {
double q =
4.0
/ viewingConditions.getC()
* Math.sqrt(j / 100.0)
* (viewingConditions.getAw() + 4.0)
* viewingConditions.getFlRoot();
double m = c * viewingConditions.getFlRoot();
double alpha = c / Math.sqrt(j / 100.0);
double s =
50.0 * Math.sqrt((alpha * viewingConditions.getC()) / (viewingConditions.getAw() + 4.0));
double hueRadians = Math.toRadians(h);
double jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j);
double mstar = 1.0 / 0.0228 * Math.log1p(0.0228 * m);
double astar = mstar * Math.cos(hueRadians);
double bstar = mstar * Math.sin(hueRadians);
return new 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.
*/
public static Cam16 fromUcs(double jstar, double astar, double bstar) {
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.
*/
public static Cam16 fromUcsInViewingConditions(
double jstar, double astar, double bstar, ViewingConditions viewingConditions) {
double m = Math.hypot(astar, bstar);
double m2 = Math.expm1(m * 0.0228) / 0.0228;
double c = m2 / viewingConditions.getFlRoot();
double h = Math.atan2(bstar, astar) * (180.0 / Math.PI);
if (h < 0.0) {
h += 360.0;
}
double j = jstar / (1. - (jstar - 100.) * 0.007);
return fromJchInViewingConditions(j, c, h, viewingConditions);
}
/**
* 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.
*/
public int toInt() {
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
*/
int viewed(ViewingConditions viewingConditions) {
double[] xyz = xyzInViewingConditions(viewingConditions, tempArray);
return ColorUtils.argbFromXyz(xyz[0], xyz[1], xyz[2]);
}
double[] xyzInViewingConditions(ViewingConditions viewingConditions, double[] returnArray) {
double alpha =
(getChroma() == 0.0 || getJ() == 0.0) ? 0.0 : getChroma() / Math.sqrt(getJ() / 100.0);
double t =
Math.pow(
alpha / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73), 1.0 / 0.9);
double hRad = Math.toRadians(getHue());
double eHue = 0.25 * (Math.cos(hRad + 2.0) + 3.8);
double ac =
viewingConditions.getAw()
* Math.pow(getJ() / 100.0, 1.0 / viewingConditions.getC() / viewingConditions.getZ());
double p1 = eHue * (50000.0 / 13.0) * viewingConditions.getNc() * viewingConditions.getNcb();
double p2 = (ac / viewingConditions.getNbb());
double hSin = Math.sin(hRad);
double hCos = Math.cos(hRad);
double gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * hCos + 108.0 * t * hSin);
double a = gamma * hCos;
double b = gamma * hSin;
double rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0;
double gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0;
double bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0;
double rCBase = max(0, (27.13 * Math.abs(rA)) / (400.0 - Math.abs(rA)));
double rC =
Math.signum(rA) * (100.0 / viewingConditions.getFl()) * Math.pow(rCBase, 1.0 / 0.42);
double gCBase = max(0, (27.13 * Math.abs(gA)) / (400.0 - Math.abs(gA)));
double gC =
Math.signum(gA) * (100.0 / viewingConditions.getFl()) * Math.pow(gCBase, 1.0 / 0.42);
double bCBase = max(0, (27.13 * Math.abs(bA)) / (400.0 - Math.abs(bA)));
double bC =
Math.signum(bA) * (100.0 / viewingConditions.getFl()) * Math.pow(bCBase, 1.0 / 0.42);
double rF = rC / viewingConditions.getRgbD()[0];
double gF = gC / viewingConditions.getRgbD()[1];
double bF = bC / viewingConditions.getRgbD()[2];
double[][] matrix = CAM16RGB_TO_XYZ;
double x = (rF * matrix[0][0]) + (gF * matrix[0][1]) + (bF * matrix[0][2]);
double y = (rF * matrix[1][0]) + (gF * matrix[1][1]) + (bF * matrix[1][2]);
double z = (rF * matrix[2][0]) + (gF * matrix[2][1]) + (bF * matrix[2][2]);
if (returnArray != null) {
returnArray[0] = x;
returnArray[1] = y;
returnArray[2] = z;
return returnArray;
} else {
return new double[] {x, y, z};
}
}
}

View File

@@ -0,0 +1,366 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package hct
import utils.ColorUtils
import utils.MathUtils
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.expm1
import kotlin.math.hypot
import kotlin.math.ln1p
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.sign
import kotlin.math.sin
import kotlin.math.sqrt
/**
* 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)
*/
@ConsistentCopyVisibility
data class Cam16
private constructor(
/** Hue in CAM16 */
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 */
val jstar: Double,
/** a* coordinate in CAM16-UCS */
val astar: Double,
/** b* coordinate in CAM16-UCS */
val bstar: Double,
) {
/**
* 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)
val dE = 1.41 * dEPrime.pow(0.63)
return dE
}
/**
* 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
*/
internal fun viewed(viewingConditions: ViewingConditions): Int {
val xyz = xyzInViewingConditions(viewingConditions)
return ColorUtils.argbFromXyz(xyz[0], xyz[1], xyz[2])
}
internal fun xyzInViewingConditions(
viewingConditions: ViewingConditions,
returnArray: DoubleArray? = null,
): 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 = Math.toRadians(hue)
val eHue = 0.25 * (cos(hRad + 2.0) + 3.8)
val ac = viewingConditions.aw * (j / 100.0).pow(1.0 / viewingConditions.c / viewingConditions.z)
val p1 = eHue * (50000.0 / 13.0) * viewingConditions.nc * viewingConditions.ncb
val p2 = 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 = max(0.0, 27.13 * abs(rA) / (400.0 - abs(rA)))
val rC = sign(rA) * (100.0 / viewingConditions.fl) * (rCBase).pow(1.0 / 0.42)
val gCBase = max(0.0, 27.13 * abs(gA) / (400.0 - abs(gA)))
val gC = sign(gA) * (100.0 / viewingConditions.fl) * (gCBase).pow(1.0 / 0.42)
val bCBase = max(0.0, 27.13 * abs(bA) / (400.0 - abs(bA)))
val bC = sign(bA) * (100.0 / viewingConditions.fl) * (bCBase).pow(1.0 / 0.42)
val rF = rC / viewingConditions.rgbD[0]
val gF = gC / viewingConditions.rgbD[1]
val bF = 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.
internal 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.
internal val CAM16RGB_TO_XYZ =
arrayOf(
doubleArrayOf(1.8620678, -1.0112547, 0.14918678),
doubleArrayOf(0.38752654, 0.62144744, -0.00897398),
doubleArrayOf(-0.0158415, -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.
*/
@JvmStatic
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.
@SuppressWarnings("FloatingPointLiteralPrecision")
internal 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)
}
internal 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 = viewingConditions.rgbD[0] * rT
val gD = viewingConditions.rgbD[1] * gT
val bD = 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 = Math.toDegrees(atan2)
val hue = MathUtils.sanitizeDegreesDouble(atanDegrees)
val hueRadians = Math.toRadians(hue)
// achromatic response to color
val ac = p2 * viewingConditions.nbb
// CAM16 lightness and brightness
val j = 100.0 * (ac / viewingConditions.aw).pow(viewingConditions.c * viewingConditions.z)
val q =
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(Math.toRadians(huePrime) + 2.0) + 3.8)
val p1 = 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 = 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
*/
internal 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 =
4.0 / viewingConditions.c *
sqrt(j / 100.0) *
(viewingConditions.aw + 4.0) *
viewingConditions.flRoot
val m = c * viewingConditions.flRoot
val alpha = c / sqrt(j / 100.0)
val s = 50.0 * sqrt(alpha * viewingConditions.c / (viewingConditions.aw + 4.0))
val hueRadians = Math.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.
*/
@JvmStatic
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.
*/
@JvmStatic
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 = 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)
}
}
}

View File

@@ -1,157 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package hct;
import utils.ColorUtils;
/**
* A color system built using CAM16 hue and chroma, and L* from L*a*b*.
*
* <p>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.
*
* <p>Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones.
*
* <p>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.
*/
public final class Hct {
private double hue;
private double chroma;
private double tone;
private int argb;
/**
* 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.
*/
public static Hct from(double hue, double chroma, double tone) {
int argb = HctSolver.solveToInt(hue, chroma, tone);
return new 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
*/
public static Hct fromInt(int argb) {
return new Hct(argb);
}
private Hct(int argb) {
setInternalState(argb);
}
public double getHue() {
return hue;
}
public double getChroma() {
return chroma;
}
public double getTone() {
return tone;
}
public int toInt() {
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.
*/
public void setHue(double newHue) {
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 < ?
*/
public void setChroma(double newChroma) {
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.
*/
public void setTone(double newTone) {
setInternalState(HctSolver.solveToInt(hue, chroma, newTone));
}
/**
* Translate a color into different ViewingConditions.
*
* <p>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.
*
* <p>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.
*
* <p>See ViewingConditions.make for parameters affecting color appearance.
*/
public Hct inViewingConditions(ViewingConditions vc) {
// 1. Use CAM16 to find XYZ coordinates of color in specified VC.
Cam16 cam16 = Cam16.fromInt(toInt());
double[] viewedInVc = cam16.xyzInViewingConditions(vc, null);
// 2. Create CAM16 of those XYZ coordinates in default VC.
Cam16 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 Hct.from(
recastInVc.getHue(), recastInVc.getChroma(), ColorUtils.lstarFromY(viewedInVc[1]));
}
private void setInternalState(int argb) {
this.argb = argb;
Cam16 cam = Cam16.fromInt(argb);
hue = cam.getHue();
chroma = cam.getChroma();
this.tone = ColorUtils.lstarFromArgb(argb);
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package hct
import utils.ColorUtils
import kotlin.math.roundToInt
/**
* 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
private var argb = 0
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))
}
override fun toString(): String {
return "HCT(${hue.roundToInt()}, ${chroma.roundToInt()}, ${tone.roundToInt()})"
}
/**
* 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, ColorUtils.lstarFromY(viewedInVc[1]))
}
private fun setInternalState(argb: Int) {
this.argb = argb
val cam = Cam16.fromInt(argb)
hue = cam.hue
chroma = cam.chroma
tone = ColorUtils.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.
*/
@JvmStatic
fun from(hue: Double, chroma: Double, tone: Double): Hct {
val argb = 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
*/
@JvmStatic
fun fromInt(argb: Int): Hct {
return Hct(argb)
}
@JvmStatic
fun isBlue(hue: Double): Boolean {
return hue >= 250 && hue < 270
}
@JvmStatic
fun isYellow(hue: Double): Boolean {
return hue >= 105 && hue < 125
}
@JvmStatic
fun isCyan(hue: Double): Boolean {
return hue >= 170 && hue < 207
}
}
}

View File

@@ -1,672 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This file is automatically generated. Do not modify it.
package hct;
import utils.ColorUtils;
import utils.MathUtils;
/** A class that solves the HCT equation. */
public class HctSolver {
private HctSolver() {}
static final double[][] SCALED_DISCOUNT_FROM_LINRGB =
new double[][] {
new double[] {
0.001200833568784504, 0.002389694492170889, 0.0002795742885861124,
},
new double[] {
0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398,
},
new double[] {
0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076,
},
};
static final double[][] LINRGB_FROM_SCALED_DISCOUNT =
new double[][] {
new double[] {
1373.2198709594231, -1100.4251190754821, -7.278681089101213,
},
new double[] {
-271.815969077903, 559.6580465940733, -32.46047482791194,
},
new double[] {
1.9622899599665666, -57.173814538844006, 308.7233197812385,
},
};
static final double[] Y_FROM_LINRGB = new double[] {0.2126, 0.7152, 0.0722};
static final double[] CRITICAL_PLANES =
new double[] {
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.
*/
static double sanitizeRadians(double angle) {
return (angle + Math.PI * 8) % (Math.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
*/
static double trueDelinearized(double rgbComponent) {
double normalized = rgbComponent / 100.0;
double delinearized = 0.0;
if (normalized <= 0.0031308) {
delinearized = normalized * 12.92;
} else {
delinearized = 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055;
}
return delinearized * 255.0;
}
static double chromaticAdaptation(double component) {
double af = Math.pow(Math.abs(component), 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.
*/
static double hueOf(double[] linrgb) {
double[] scaledDiscount = MathUtils.matrixMultiply(linrgb, SCALED_DISCOUNT_FROM_LINRGB);
double rA = chromaticAdaptation(scaledDiscount[0]);
double gA = chromaticAdaptation(scaledDiscount[1]);
double bA = chromaticAdaptation(scaledDiscount[2]);
// redness-greenness
double a = (11.0 * rA + -12.0 * gA + bA) / 11.0;
// yellowness-blueness
double b = (rA + gA - 2.0 * bA) / 9.0;
return Math.atan2(b, a);
}
static boolean areInCyclicOrder(double a, double b, double c) {
double deltaAB = sanitizeRadians(b - a);
double 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.
*/
static double intercept(double source, double mid, double target) {
return (mid - source) / (target - source);
}
static double[] lerpPoint(double[] source, double t, double[] target) {
return new double[] {
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
*/
static double[] setCoordinate(double[] source, double coordinate, double[] target, int axis) {
double t = intercept(source[axis], coordinate, target[axis]);
return lerpPoint(source, t, target);
}
static boolean isBounded(double x) {
return 0.0 <= x && x <= 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.
*/
static double[] nthVertex(double y, int n) {
double kR = Y_FROM_LINRGB[0];
double kG = Y_FROM_LINRGB[1];
double kB = Y_FROM_LINRGB[2];
double coordA = n % 4 <= 1 ? 0.0 : 100.0;
double coordB = n % 2 == 0 ? 0.0 : 100.0;
if (n < 4) {
double g = coordA;
double b = coordB;
double r = (y - g * kG - b * kB) / kR;
if (isBounded(r)) {
return new double[] {r, g, b};
} else {
return new double[] {-1.0, -1.0, -1.0};
}
} else if (n < 8) {
double b = coordA;
double r = coordB;
double g = (y - r * kR - b * kB) / kG;
if (isBounded(g)) {
return new double[] {r, g, b};
} else {
return new double[] {-1.0, -1.0, -1.0};
}
} else {
double r = coordA;
double g = coordB;
double b = (y - r * kR - g * kG) / kB;
if (isBounded(b)) {
return new double[] {r, g, b};
} else {
return new double[] {-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.
*/
static double[][] bisectToSegment(double y, double targetHue) {
double[] left = new double[] {-1.0, -1.0, -1.0};
double[] right = left;
double leftHue = 0.0;
double rightHue = 0.0;
boolean initialized = false;
boolean uncut = true;
for (int n = 0; n < 12; n++) {
double[] mid = nthVertex(y, n);
if (mid[0] < 0) {
continue;
}
double 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 new double[][] {left, right};
}
static double[] midpoint(double[] a, double[] b) {
return new double[] {
(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2,
};
}
static int criticalPlaneBelow(double x) {
return (int) Math.floor(x - 0.5);
}
static int criticalPlaneAbove(double x) {
return (int) Math.ceil(x - 0.5);
}
/**
* 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.
*/
static double[] bisectToLimit(double y, double targetHue) {
double[][] segment = bisectToSegment(y, targetHue);
double[] left = segment[0];
double leftHue = hueOf(left);
double[] right = segment[1];
for (int axis = 0; axis < 3; axis++) {
if (left[axis] != right[axis]) {
int lPlane = -1;
int rPlane = 255;
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 (int i = 0; i < 8; i++) {
if (Math.abs(rPlane - lPlane) <= 1) {
break;
} else {
int mPlane = (int) Math.floor((lPlane + rPlane) / 2.0);
double midPlaneCoordinate = CRITICAL_PLANES[mPlane];
double[] mid = setCoordinate(left, midPlaneCoordinate, right, axis);
double midHue = hueOf(mid);
if (areInCyclicOrder(leftHue, targetHue, midHue)) {
right = mid;
rPlane = mPlane;
} else {
left = mid;
leftHue = midHue;
lPlane = mPlane;
}
}
}
}
}
return midpoint(left, right);
}
static double inverseChromaticAdaptation(double adapted) {
double adaptedAbs = Math.abs(adapted);
double base = Math.max(0, 27.13 * adaptedAbs / (400.0 - adaptedAbs));
return MathUtils.signum(adapted) * Math.pow(base, 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.
*/
static int findResultByJ(double hueRadians, double chroma, double y) {
// Initial estimate of j.
double j = Math.sqrt(y) * 11.0;
// ===========================================================
// Operations inlined from Cam16 to avoid repeated calculation
// ===========================================================
ViewingConditions viewingConditions = ViewingConditions.DEFAULT;
double tInnerCoeff = 1 / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73);
double eHue = 0.25 * (Math.cos(hueRadians + 2.0) + 3.8);
double p1 = eHue * (50000.0 / 13.0) * viewingConditions.getNc() * viewingConditions.getNcb();
double hSin = Math.sin(hueRadians);
double hCos = Math.cos(hueRadians);
for (int iterationRound = 0; iterationRound < 5; iterationRound++) {
// ===========================================================
// Operations inlined from Cam16 to avoid repeated calculation
// ===========================================================
double jNormalized = j / 100.0;
double alpha = chroma == 0.0 || j == 0.0 ? 0.0 : chroma / Math.sqrt(jNormalized);
double t = Math.pow(alpha * tInnerCoeff, 1.0 / 0.9);
double ac =
viewingConditions.getAw()
* Math.pow(jNormalized, 1.0 / viewingConditions.getC() / viewingConditions.getZ());
double p2 = ac / viewingConditions.getNbb();
double gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11 * t * hCos + 108.0 * t * hSin);
double a = gamma * hCos;
double b = gamma * hSin;
double rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0;
double gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0;
double bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0;
double rCScaled = inverseChromaticAdaptation(rA);
double gCScaled = inverseChromaticAdaptation(gA);
double bCScaled = inverseChromaticAdaptation(bA);
double[] linrgb =
MathUtils.matrixMultiply(
new double[] {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;
}
double kR = Y_FROM_LINRGB[0];
double kG = Y_FROM_LINRGB[1];
double kB = Y_FROM_LINRGB[2];
double fnj = kR * linrgb[0] + kG * linrgb[1] + kB * linrgb[2];
if (fnj <= 0) {
return 0;
}
if (iterationRound == 4 || Math.abs(fnj - y) < 0.002) {
if (linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01) {
return 0;
}
return 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.
*/
public static int solveToInt(double hueDegrees, double chroma, double lstar) {
if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) {
return ColorUtils.argbFromLstar(lstar);
}
hueDegrees = MathUtils.sanitizeDegreesDouble(hueDegrees);
double hueRadians = hueDegrees / 180 * Math.PI;
double y = ColorUtils.yFromLstar(lstar);
int exactAnswer = findResultByJ(hueRadians, chroma, y);
if (exactAnswer != 0) {
return exactAnswer;
}
double[] 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.
*/
public static Cam16 solveToCam(double hueDegrees, double chroma, double lstar) {
return Cam16.fromInt(solveToInt(hueDegrees, chroma, lstar));
}
}

View File

@@ -0,0 +1,670 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This file is automatically generated. Do not modify it.
package hct
import utils.ColorUtils
import utils.MathUtils
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.sign
import kotlin.math.sin
import kotlin.math.sqrt
/** A class that solves the HCT equation. */
object HctSolver {
private 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),
)
private 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),
)
private val Y_FROM_LINRGB = doubleArrayOf(0.2126, 0.7152, 0.0722)
private 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.
*/
internal 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
*/
internal fun trueDelinearized(rgbComponent: Double): Double {
val normalized = rgbComponent / 100.0
val delinearized: Double =
if (normalized <= 0.0031308) {
normalized * 12.92
} else {
1.055 * normalized.pow(1.0 / 2.4) - 0.055
}
return delinearized * 255.0
}
internal fun chromaticAdaptation(component: Double): Double {
val af = abs(component).pow(0.42)
return sign(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.
*/
internal 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)
}
internal 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.
*/
internal fun intercept(source: Double, mid: Double, target: Double): Double {
return (mid - source) / (target - source)
}
internal 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
*/
internal fun setCoordinate(
source: DoubleArray,
coordinate: Double,
target: DoubleArray,
axis: Int,
): DoubleArray {
val t = intercept(source[axis], coordinate, target[axis])
return lerpPoint(source, t, target)
}
internal fun isBounded(x: Double): Boolean {
return 0.0 <= x && x <= 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,
* null is returned.
*/
internal 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 when {
n < 4 -> {
val g = coordA
val b = coordB
val r = (y - g * kG - b * kB) / kR
if (isBounded(r)) {
doubleArrayOf(r, g, b)
} else {
null
}
}
n < 8 -> {
val b = coordA
val r = coordB
val g = (y - r * kR - b * kB) / kG
if (isBounded(g)) {
doubleArrayOf(r, g, b)
} else {
null
}
}
else -> {
val r = coordA
val g = coordB
val b = (y - r * kR - g * kG) / kB
if (isBounded(b)) {
doubleArrayOf(r, g, b)
} else {
null
}
}
}
}
/**
* 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.
*/
internal fun bisectToSegment(y: Double, targetHue: Double): Array<DoubleArray> {
var left: DoubleArray? = null
var right: DoubleArray? = null
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 != null) {
val midHue = hueOf(mid)
if (!initialized) {
left = mid
right = mid
leftHue = midHue
rightHue = midHue
initialized = true
} else 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!!)
}
internal fun midpoint(a: DoubleArray, b: DoubleArray): DoubleArray {
return doubleArrayOf((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2)
}
internal fun criticalPlaneBelow(x: Double): Int {
return floor(x - 0.5).toInt()
}
internal 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.
*/
internal 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 =
if (left[axis] < right[axis]) {
criticalPlaneBelow(trueDelinearized(left[axis]))
} else {
criticalPlaneAbove(trueDelinearized(left[axis]))
}
var rPlane =
if (left[axis] < right[axis]) {
criticalPlaneAbove(trueDelinearized(right[axis]))
} else {
criticalPlaneBelow(trueDelinearized(right[axis]))
}
for (i in 0..7) {
if (abs((rPlane - lPlane).toDouble()) <= 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)
}
internal fun inverseChromaticAdaptation(adapted: Double): Double {
val adaptedAbs = abs(adapted)
val base = Math.max(0.0, 27.13 * adaptedAbs / (400.0 - adaptedAbs))
return sign(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.
*/
internal 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 -= (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 {
if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) {
return ColorUtils.argbFromLstar(lstar)
}
val hueRadians = MathUtils.sanitizeDegreesDouble(hueDegrees) / 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 A 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))
}
}

View File

@@ -1,205 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package hct;
import utils.ColorUtils;
import utils.MathUtils;
/**
* 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.
*
* <p>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)
*
* <p>This class caches intermediate values of the CAM16 conversion process that depend only on
* viewing conditions, enabling speed ups.
*/
public final class ViewingConditions {
/** sRGB-like viewing conditions. */
public static final ViewingConditions DEFAULT =
ViewingConditions.defaultWithBackgroundLstar(50.0);
private final double aw;
private final double nbb;
private final double ncb;
private final double c;
private final double nc;
private final double n;
private final double[] rgbD;
private final double fl;
private final double flRoot;
private final double z;
public double getAw() {
return aw;
}
public double getN() {
return n;
}
public double getNbb() {
return nbb;
}
double getNcb() {
return ncb;
}
double getC() {
return c;
}
double getNc() {
return nc;
}
public double[] getRgbD() {
return rgbD;
}
double getFl() {
return fl;
}
public double getFlRoot() {
return flRoot;
}
double getZ() {
return z;
}
/**
* 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.
*/
public static ViewingConditions make(
double[] whitePoint,
double adaptingLuminance,
double backgroundLstar,
double surround,
boolean discountingIlluminant) {
// 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.
backgroundLstar = Math.max(0.1, backgroundLstar);
// Transform white point XYZ to 'cone'/'rgb' responses
double[][] matrix = Cam16.XYZ_TO_CAM16RGB;
double[] xyz = whitePoint;
double rW = (xyz[0] * matrix[0][0]) + (xyz[1] * matrix[0][1]) + (xyz[2] * matrix[0][2]);
double gW = (xyz[0] * matrix[1][0]) + (xyz[1] * matrix[1][1]) + (xyz[2] * matrix[1][2]);
double bW = (xyz[0] * matrix[2][0]) + (xyz[1] * matrix[2][1]) + (xyz[2] * matrix[2][2]);
double f = 0.8 + (surround / 10.0);
double c =
(f >= 0.9)
? MathUtils.lerp(0.59, 0.69, ((f - 0.9) * 10.0))
: MathUtils.lerp(0.525, 0.59, ((f - 0.8) * 10.0));
double d =
discountingIlluminant
? 1.0
: f * (1.0 - ((1.0 / 3.6) * Math.exp((-adaptingLuminance - 42.0) / 92.0)));
d = MathUtils.clampDouble(0.0, 1.0, d);
double nc = f;
double[] rgbD =
new double[] {
d * (100.0 / rW) + 1.0 - d, d * (100.0 / gW) + 1.0 - d, d * (100.0 / bW) + 1.0 - d
};
double k = 1.0 / (5.0 * adaptingLuminance + 1.0);
double k4 = k * k * k * k;
double k4F = 1.0 - k4;
double fl = (k4 * adaptingLuminance) + (0.1 * k4F * k4F * Math.cbrt(5.0 * adaptingLuminance));
double n = (ColorUtils.yFromLstar(backgroundLstar) / whitePoint[1]);
double z = 1.48 + Math.sqrt(n);
double nbb = 0.725 / Math.pow(n, 0.2);
double ncb = nbb;
double[] rgbAFactors =
new double[] {
Math.pow(fl * rgbD[0] * rW / 100.0, 0.42),
Math.pow(fl * rgbD[1] * gW / 100.0, 0.42),
Math.pow(fl * rgbD[2] * bW / 100.0, 0.42)
};
double[] rgbA =
new double[] {
(400.0 * rgbAFactors[0]) / (rgbAFactors[0] + 27.13),
(400.0 * rgbAFactors[1]) / (rgbAFactors[1] + 27.13),
(400.0 * rgbAFactors[2]) / (rgbAFactors[2] + 27.13)
};
double aw = ((2.0 * rgbA[0]) + rgbA[1] + (0.05 * rgbA[2])) * nbb;
return new ViewingConditions(n, aw, nbb, ncb, c, nc, rgbD, fl, Math.pow(fl, 0.25), z);
}
/**
* Create sRGB-like viewing conditions with a custom background lstar.
*
* <p>Default viewing conditions have a lstar of 50, midgray.
*/
public static ViewingConditions defaultWithBackgroundLstar(double lstar) {
return ViewingConditions.make(
ColorUtils.whitePointD65(),
(200.0 / Math.PI * ColorUtils.yFromLstar(50.0) / 100.f),
lstar,
2.0,
false);
}
/**
* 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 ViewingConditions(
double n,
double aw,
double nbb,
double ncb,
double c,
double nc,
double[] rgbD,
double fl,
double flRoot,
double z) {
this.n = n;
this.aw = aw;
this.nbb = nbb;
this.ncb = ncb;
this.c = c;
this.nc = nc;
this.rgbD = rgbD;
this.fl = fl;
this.flRoot = flRoot;
this.z = z;
}
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package hct
import utils.ColorUtils
import utils.MathUtils
import kotlin.math.PI
import kotlin.math.cbrt
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.sqrt
/**
* 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.
*/
@ConsistentCopyVisibility
data 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,
internal val ncb: Double,
internal val c: Double,
internal val nc: Double,
val rgbD: DoubleArray,
internal val fl: Double,
val flRoot: Double,
internal 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.
*/
@JvmStatic
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.
val backgroundLstar = max(0.1, backgroundLstar)
// Transform white point XYZ to 'cone'/'rgb' responses
val matrix = Cam16.XYZ_TO_CAM16RGB
val xyz = whitePoint
val rW = xyz[0] * matrix[0][0] + xyz[1] * matrix[0][1] + xyz[2] * matrix[0][2]
val gW = xyz[0] * matrix[1][0] + xyz[1] * matrix[1][1] + xyz[2] * matrix[1][2]
val bW = xyz[0] * matrix[2][0] + xyz[1] * matrix[2][1] + xyz[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 = d.coerceIn(0.0, 1.0)
val nc = f
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 ncb = nbb
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, ncb, c, nc, 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.
*/
@JvmStatic
fun defaultWithBackgroundLstar(lstar: Double): ViewingConditions {
return make(
ColorUtils.whitePointD65(),
200.0 / PI * ColorUtils.yFromLstar(50.0) / 100f,
lstar,
2.0,
false,
)
}
}
}

View File

@@ -1,82 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package palettes;
import static java.lang.Math.max;
import static java.lang.Math.min;
import hct.Hct;
/**
* 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.
*/
public final class CorePalette {
public TonalPalette a1;
public TonalPalette a2;
public TonalPalette a3;
public TonalPalette n1;
public TonalPalette n2;
public TonalPalette error;
/**
* Create key tones from a color.
*
* @param argb ARGB representation of a color
*/
public static CorePalette of(int argb) {
return new CorePalette(argb, false);
}
/**
* Create content key tones from a color.
*
* @param argb ARGB representation of a color
*/
public static CorePalette contentOf(int argb) {
return new CorePalette(argb, true);
}
public CorePalette(TonalPalette a1, TonalPalette a2, TonalPalette a3, TonalPalette n1, TonalPalette n2, TonalPalette error) {
this.a1 = a1;
this.a2 = a2;
this.a3 = a3;
this.n1 = n1;
this.n2 = n2;
this.error = error;
}
private CorePalette(int argb, boolean isContent) {
Hct hct = Hct.fromInt(argb);
double hue = hct.getHue();
double chroma = hct.getChroma();
if (isContent) {
this.a1 = TonalPalette.fromHueAndChroma(hue, chroma);
this.a2 = TonalPalette.fromHueAndChroma(hue, chroma / 3.);
this.a3 = TonalPalette.fromHueAndChroma(hue + 60., chroma / 2.);
this.n1 = TonalPalette.fromHueAndChroma(hue, min(chroma / 12., 4.));
this.n2 = TonalPalette.fromHueAndChroma(hue, min(chroma / 6., 8.));
} else {
this.a1 = TonalPalette.fromHueAndChroma(hue, max(48., chroma));
this.a2 = TonalPalette.fromHueAndChroma(hue, 16.);
this.a3 = TonalPalette.fromHueAndChroma(hue + 60., 24.);
this.n1 = TonalPalette.fromHueAndChroma(hue, 4.);
this.n2 = TonalPalette.fromHueAndChroma(hue, 8.);
}
this.error = TonalPalette.fromHueAndChroma(25, 84.);
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package palettes
/**
* Comprises foundational palettes to build a color scheme.
*
* Generated from a source color, these palettes will then be part of a [DynamicScheme] together
* with appearance preferences.
*/
data class CorePalettes(
val primary: TonalPalette,
val secondary: TonalPalette,
val tertiary: TonalPalette,
val neutral: TonalPalette,
val neutralVariant: TonalPalette,
)

View File

@@ -1,144 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package palettes;
import hct.Hct;
import java.util.HashMap;
import java.util.Map;
/**
* A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone.
*/
public final class TonalPalette {
Map<Integer, Integer> cache;
Hct keyColor;
double hue;
double chroma;
/**
* 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.
*/
public static TonalPalette fromInt(int argb) {
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.
*/
public static TonalPalette fromHct(Hct hct) {
return new TonalPalette(hct.getHue(), hct.getChroma(), hct);
}
/**
* Create tones from a defined HCT hue and chroma.
*
* @param hue HCT hue
* @param chroma HCT chroma
* @return Tones matching hue and chroma.
*/
public static TonalPalette fromHueAndChroma(double hue, double chroma) {
return new TonalPalette(hue, chroma, createKeyColor(hue, chroma));
}
private TonalPalette(double hue, double chroma, Hct keyColor) {
cache = new HashMap<>();
this.hue = hue;
this.chroma = chroma;
this.keyColor = keyColor;
}
/** The key color is the first tone, starting from T50, matching the given hue and chroma. */
private static Hct createKeyColor(double hue, double chroma) {
double startTone = 50.0;
Hct smallestDeltaHct = Hct.from(hue, chroma, startTone);
double smallestDelta = Math.abs(smallestDeltaHct.getChroma() - chroma);
// Starting from T50, check T+/-delta to see if they match the requested
// chroma.
//
// Starts from T50 because T50 has the most chroma available, on
// average. Thus it is most likely to have a direct answer and minimize
// iteration.
for (double delta = 1.0; delta < 50.0; delta += 1.0) {
// Termination condition rounding instead of minimizing delta to avoid
// case where requested chroma is 16.51, and the closest chroma is 16.49.
// Error is minimized, but when rounded and displayed, requested chroma
// is 17, key color's chroma is 16.
if (Math.round(chroma) == Math.round(smallestDeltaHct.getChroma())) {
return smallestDeltaHct;
}
final Hct hctAdd = Hct.from(hue, chroma, startTone + delta);
final double hctAddDelta = Math.abs(hctAdd.getChroma() - chroma);
if (hctAddDelta < smallestDelta) {
smallestDelta = hctAddDelta;
smallestDeltaHct = hctAdd;
}
final Hct hctSubtract = Hct.from(hue, chroma, startTone - delta);
final double hctSubtractDelta = Math.abs(hctSubtract.getChroma() - chroma);
if (hctSubtractDelta < smallestDelta) {
smallestDelta = hctSubtractDelta;
smallestDeltaHct = hctSubtract;
}
}
return smallestDeltaHct;
}
/**
* 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)
@SuppressWarnings("ComputeIfAbsentUseValue")
public int tone(int tone) {
Integer color = cache.get(tone);
if (color == null) {
color = Hct.from(this.hue, this.chroma, tone).toInt();
cache.put(tone, color);
}
return color;
}
/** Given a tone, use hue and chroma of palette to create a color, and return it as HCT. */
public Hct getHct(double tone) {
return Hct.from(this.hue, this.chroma, tone);
}
/** The chroma of the Tonal Palette, in HCT. Ranges from 0 to ~130 (for sRGB gamut). */
public double getChroma() {
return this.chroma;
}
/** The hue of the Tonal Palette, in HCT. Ranges from 0 to 360. */
public double getHue() {
return this.hue;
}
/** The key color is the first tone, starting from T50, that matches the palette's chroma. */
public Hct getKeyColor() {
return this.keyColor;
}
}

View File

@@ -0,0 +1,174 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package palettes
import hct.Hct
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone.
*
* TonalPalette is intended for use in a single thread due to its stateful caching.
*/
class TonalPalette
private constructor(
/** The hue of the Tonal Palette, in HCT. Ranges from 0 to 360. */
val hue: Double,
/** The chroma of the Tonal Palette, in HCT. Ranges from 0 to ~130 (for sRGB gamut). */
val chroma: Double,
/** The key color is the first tone, starting from T50, that matches the palette's chroma. */
val keyColor: Hct,
) {
var cache: MutableMap<Int, Int> = mutableMapOf()
/**
* 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.
*/
fun tone(tone: Int): Int {
var color = cache[tone]
if (color == null) {
color =
if (tone == 99 && Hct.isYellow(hue)) {
averageArgb(tone(98), tone(100))
} else {
Hct.from(hue, chroma, tone.toDouble()).toInt()
}
cache[tone] = color
}
return color
}
/** Given a tone, use hue and chroma of palette to create a color, and return it as HCT. */
fun getHct(tone: Double): Hct {
return Hct.from(hue, chroma, tone)
}
private fun averageArgb(argb1: Int, argb2: Int): Int {
val red1 = argb1 ushr 16 and 0xff
val green1 = argb1 ushr 8 and 0xff
val blue1 = argb1 and 0xff
val red2 = argb2 ushr 16 and 0xff
val green2 = argb2 ushr 8 and 0xff
val blue2 = argb2 and 0xff
val red = ((red1 + red2) / 2f).roundToInt()
val green = ((green1 + green2) / 2f).roundToInt()
val blue = ((blue1 + blue2) / 2f).roundToInt()
return 255 shl 24 or (red and 255) shl 16 or (green and 255) shl 8 or (blue and 255)
}
/** Key color is a color that represents the hue and chroma of a tonal palette. */
private class KeyColor(private val hue: Double, private val requestedChroma: Double) {
// Cache that maps tone to max chroma to avoid duplicated HCT calculation.
private val chromaCache: MutableMap<Int, Double> = mutableMapOf()
/**
* Creates a key color from a [hue] and a [chroma]. The key color is the first tone, starting
* from T50, matching the given hue and chroma.
*
* @return Key color [Hct]
*/
fun create(): Hct {
// Pivot around T50 because T50 has the most chroma available, on
// average. Thus it is most likely to have a direct answer.
val pivotTone = 50
val toneStepSize = 1
// Epsilon to accept values slightly higher than the requested chroma.
val epsilon = 0.01
// Binary search to find the tone that can provide a chroma that is closest
// to the requested chroma.
var lowerTone = 0
var upperTone = 100
while (lowerTone < upperTone) {
val midTone = (lowerTone + upperTone) / 2
val isAscending = maxChroma(midTone) < maxChroma(midTone + toneStepSize)
val sufficientChroma = maxChroma(midTone) >= requestedChroma - epsilon
if (sufficientChroma) {
// Either range [lowerTone, midTone] or [midTone, upperTone] has
// the answer, so search in the range that is closer the pivot tone.
if (abs((lowerTone - pivotTone).toDouble()) < abs((upperTone - pivotTone).toDouble())) {
upperTone = midTone
} else {
if (lowerTone == midTone) {
return Hct.from(hue, requestedChroma, lowerTone.toDouble())
}
lowerTone = midTone
}
} else {
// As there is no sufficient chroma in the midTone, follow the direction to the chroma
// peak.
if (isAscending) {
lowerTone = midTone + toneStepSize
} else {
// Keep midTone for potential chroma peak.
upperTone = midTone
}
}
}
return Hct.from(hue, requestedChroma, lowerTone.toDouble())
}
// Find the maximum chroma for a given tone
private fun maxChroma(tone: Int): Double {
return chromaCache.getOrPut(tone) { Hct.from(hue, MAX_CHROMA_VALUE, tone.toDouble()).chroma }
}
companion object {
private const val MAX_CHROMA_VALUE = 200.0
}
}
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.
*/
@JvmStatic
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.
*/
@JvmStatic
fun fromHct(hct: Hct): TonalPalette {
return TonalPalette(hct.hue, hct.chroma, hct)
}
/**
* Create tones from a defined HCT hue and chroma.
*
* @param hue HCT hue
* @param chroma HCT chroma
* @return Tones matching hue and chroma.
*/
@JvmStatic
fun fromHueAndChroma(hue: Double, chroma: Double): TonalPalette {
val keyColor = KeyColor(hue, chroma).create()
return TonalPalette(hue, chroma, keyColor)
}
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package quantize
/** An interface to allow use of different color spaces by quantizers. */
interface PointProvider {
/** The four components in the color space of an sRGB color. */
fun fromInt(argb: Int): DoubleArray
/** The ARGB (i.e. hex code) representation of this color. */
fun toInt(point: DoubleArray): Int
/**
* Squared distance between two colors. Distance is defined by scientific color spaces and
* referred to as delta E.
*/
fun distance(a: DoubleArray, b: DoubleArray): Double
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package quantize
import utils.ColorUtils
/**
* 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 = ColorUtils.labFromArgb(argb)
return doubleArrayOf(lab[0], lab[1], lab[2])
}
/** Convert a 3-element array to a color represented in ARGB. */
override fun toInt(point: DoubleArray): Int {
return ColorUtils.argbFromLab(point[0], point[1], point[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(a: DoubleArray, b: DoubleArray): Double {
val dL = a[0] - b[0]
val dA = a[1] - b[1]
val dB = a[2] - b[2]
return dL * dL + dA * dA + dB * dB
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package quantize
internal interface Quantizer {
fun quantize(pixels: IntArray, maxColors: Int): QuantizerResult
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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<Int, Int> {
val wu = QuantizerWu()
val wuResult = 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)
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package quantize
import java.util.LinkedHashMap
/** Creates a dictionary with keys of colors, and values of count of the color */
class QuantizerMap : Quantizer {
var colorToCount: MutableMap<Int, Int>? = null
private set
override fun quantize(pixels: IntArray, maxColors: Int): QuantizerResult {
val pixelByCount: MutableMap<Int, Int> = 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)
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package quantize
/** Represents result of a quantizer run */
data class QuantizerResult(val colorToCount: Map<Int, Int>)

View File

@@ -0,0 +1,188 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package quantize
import java.util.Arrays
import java.util.LinkedHashMap
import java.util.Random
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.sqrt
/**
* 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 class Distance() : Comparable<Distance> {
var index = -1
var distance = -1.0
override fun compareTo(other: Distance): Int {
return distance.compareTo(other.distance)
}
}
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<Int, Int> {
// Uses a seeded random number generator to ensure consistent results.
val random = Random(0x42688L)
val pixelToCount: MutableMap<Int, Int> = LinkedHashMap()
val points = arrayOfNulls<DoubleArray>(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 = min(maxColors, pointCount)
if (startingClusters.isNotEmpty()) {
clusterCount = min(clusterCount, startingClusters.size)
}
val clusters = arrayOfNulls<DoubleArray>(clusterCount)
var clustersCreated = 0
for (i in startingClusters.indices) {
clusters[i] = pointProvider.fromInt(startingClusters[i])
clustersCreated++
}
val additionalClustersNeeded = clusterCount - clustersCreated
if (additionalClustersNeeded > 0) {
for (i in 0 until additionalClustersNeeded) {}
}
val clusterIndices = IntArray(pointCount) { random.nextInt(clusterCount) }
val indexMatrix = Array(clusterCount) { IntArray(clusterCount) }
val distanceToIndexMatrix = 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
}
Arrays.sort(distanceToIndexMatrix[i])
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] = doubleArrayOf(a, b, c)
}
}
val argbToPopulation: MutableMap<Int, Int> = 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
}
}

View File

@@ -0,0 +1,363 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package quantize
import utils.ColorUtils
import java.util.ArrayList
import java.util.LinkedHashMap
/**
* 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 {
private lateinit var weights: IntArray
private lateinit var momentsR: IntArray
private lateinit var momentsG: IntArray
private lateinit var momentsB: IntArray
private lateinit var moments: DoubleArray
private lateinit var cubes: Array<Box>
override fun quantize(pixels: IntArray, maxColors: Int): QuantizerResult {
val mapResult = QuantizerMap().quantize(pixels, maxColors)
constructHistogram(mapResult.colorToCount)
createMoments()
val createBoxesResult = createBoxes(maxColors)
val colors = createResult(createBoxesResult.resultCount)
val resultMap: MutableMap<Int, Int> = LinkedHashMap()
for (color in colors) {
resultMap[color] = 0
}
return QuantizerResult(resultMap)
}
fun constructHistogram(pixels: Map<Int, Int>) {
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 = ColorUtils.redFromArgb(pixel)
val green = ColorUtils.greenFromArgb(pixel)
val blue = ColorUtils.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]
}
}
}
}
internal fun createBoxes(maxColorCount: Int): CreateBoxesResult {
cubes = Array(maxColorCount) { 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<Int> {
val colors: MutableList<Int> = 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
}
internal 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()
}
internal 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 maxR = maxRResult.maximum
val maxG = maxGResult.maximum
val maxB = maxBResult.maximum
val cutDirection =
when {
maxR >= maxG && maxR >= maxB -> {
if (maxRResult.cutLocation < 0) {
return false
}
Direction.RED
}
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
}
internal 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)
}
internal enum class Direction {
RED,
GREEN,
BLUE,
}
internal class MaximizeResult(
// < 0 if cut impossible
var cutLocation: Int,
var maximum: Double,
)
internal class CreateBoxesResult(var requestedCount: Int, var resultCount: Int)
internal 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
}
internal 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)])
}
internal 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)]
}
}
internal 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)])
}
}
}
}

View File

@@ -1,953 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This file is automatically generated. Do not modify it.
package scheme;
import palettes.CorePalette;
/**
* Represents a Material color scheme, a mapping of color roles to colors.
*/
public class Scheme {
private int primary;
private int onPrimary;
private int primaryContainer;
private int onPrimaryContainer;
private int secondary;
private int onSecondary;
private int secondaryContainer;
private int onSecondaryContainer;
private int tertiary;
private int onTertiary;
private int tertiaryContainer;
private int onTertiaryContainer;
private int error;
private int onError;
private int errorContainer;
private int onErrorContainer;
private int background;
private int onBackground;
private int surface;
private int onSurface;
private int surfaceVariant;
private int onSurfaceVariant;
private int outline;
private int outlineVariant;
private int shadow;
private int scrim;
private int inverseSurface;
private int inverseOnSurface;
private int inversePrimary;
private int surfaceDim;
private int surfaceBright;
private int surfaceContainerLowest;
private int surfaceContainerLow;
private int surfaceContainer;
private int surfaceContainerHigh;
private int surfaceContainerHighest;
public Scheme() {
}
public Scheme(
int 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 surfaceDim,
int surfaceBright,
int surfaceContainerLowest,
int surfaceContainerLow,
int surfaceContainer,
int surfaceContainerHigh,
int surfaceContainerHighest
) {
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;
this.surfaceDim = surfaceDim;
this.surfaceBright = surfaceBright;
this.surfaceContainerLowest = surfaceContainerLowest;
this.surfaceContainerLow = surfaceContainerLow;
this.surfaceContainer = surfaceContainer;
this.surfaceContainerHigh = surfaceContainerHigh;
this.surfaceContainerHighest = surfaceContainerHighest;
}
public static Scheme light(int argb) {
return lightFromCorePalette(CorePalette.of(argb));
}
public static Scheme dark(int argb) {
return darkFromCorePalette(CorePalette.of(argb));
}
public static Scheme lightContent(int argb) {
return lightFromCorePalette(CorePalette.contentOf(argb));
}
public static Scheme darkContent(int argb) {
return darkFromCorePalette(CorePalette.contentOf(argb));
}
public static Scheme lightFromCorePalette(CorePalette core) {
return new 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(98))
.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))
.withSurfaceDim(core.n1.tone(87))
.withSurfaceBright(core.n1.tone(98))
.withSurfaceContainerLowest(core.n1.tone(100))
.withSurfaceContainerLow(core.n1.tone(96))
.withSurfaceContainer(core.n1.tone(94))
.withSurfaceContainerHigh(core.n1.tone(92))
.withSurfaceContainerHighest(core.n1.tone(90));
}
public static Scheme darkFromCorePalette(CorePalette core) {
return new 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(6))
.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))
.withSurfaceDim(core.n1.tone(6))
.withSurfaceBright(core.n1.tone(24))
.withSurfaceContainerLowest(core.n1.tone(4))
.withSurfaceContainerLow(core.n1.tone(10))
.withSurfaceContainer(core.n1.tone(12))
.withSurfaceContainerHigh(core.n1.tone(17))
.withSurfaceContainerHighest(core.n1.tone(22));
}
public int getPrimary() {
return primary;
}
public void setPrimary(int primary) {
this.primary = primary;
}
public Scheme withPrimary(int primary) {
this.primary = primary;
return this;
}
public int getOnPrimary() {
return onPrimary;
}
public void setOnPrimary(int onPrimary) {
this.onPrimary = onPrimary;
}
public Scheme withOnPrimary(int onPrimary) {
this.onPrimary = onPrimary;
return this;
}
public int getPrimaryContainer() {
return primaryContainer;
}
public void setPrimaryContainer(int primaryContainer) {
this.primaryContainer = primaryContainer;
}
public Scheme withPrimaryContainer(int primaryContainer) {
this.primaryContainer = primaryContainer;
return this;
}
public int getOnPrimaryContainer() {
return onPrimaryContainer;
}
public void setOnPrimaryContainer(int onPrimaryContainer) {
this.onPrimaryContainer = onPrimaryContainer;
}
public Scheme withOnPrimaryContainer(int onPrimaryContainer) {
this.onPrimaryContainer = onPrimaryContainer;
return this;
}
public int getSecondary() {
return secondary;
}
public void setSecondary(int secondary) {
this.secondary = secondary;
}
public Scheme withSecondary(int secondary) {
this.secondary = secondary;
return this;
}
public int getOnSecondary() {
return onSecondary;
}
public void setOnSecondary(int onSecondary) {
this.onSecondary = onSecondary;
}
public Scheme withOnSecondary(int onSecondary) {
this.onSecondary = onSecondary;
return this;
}
public int getSecondaryContainer() {
return secondaryContainer;
}
public void setSecondaryContainer(int secondaryContainer) {
this.secondaryContainer = secondaryContainer;
}
public Scheme withSecondaryContainer(int secondaryContainer) {
this.secondaryContainer = secondaryContainer;
return this;
}
public int getOnSecondaryContainer() {
return onSecondaryContainer;
}
public void setOnSecondaryContainer(int onSecondaryContainer) {
this.onSecondaryContainer = onSecondaryContainer;
}
public Scheme withOnSecondaryContainer(int onSecondaryContainer) {
this.onSecondaryContainer = onSecondaryContainer;
return this;
}
public int getTertiary() {
return tertiary;
}
public void setTertiary(int tertiary) {
this.tertiary = tertiary;
}
public Scheme withTertiary(int tertiary) {
this.tertiary = tertiary;
return this;
}
public int getOnTertiary() {
return onTertiary;
}
public void setOnTertiary(int onTertiary) {
this.onTertiary = onTertiary;
}
public Scheme withOnTertiary(int onTertiary) {
this.onTertiary = onTertiary;
return this;
}
public int getTertiaryContainer() {
return tertiaryContainer;
}
public void setTertiaryContainer(int tertiaryContainer) {
this.tertiaryContainer = tertiaryContainer;
}
public Scheme withTertiaryContainer(int tertiaryContainer) {
this.tertiaryContainer = tertiaryContainer;
return this;
}
public int getOnTertiaryContainer() {
return onTertiaryContainer;
}
public void setOnTertiaryContainer(int onTertiaryContainer) {
this.onTertiaryContainer = onTertiaryContainer;
}
public Scheme withOnTertiaryContainer(int onTertiaryContainer) {
this.onTertiaryContainer = onTertiaryContainer;
return this;
}
public int getError() {
return error;
}
public void setError(int error) {
this.error = error;
}
public Scheme withError(int error) {
this.error = error;
return this;
}
public int getOnError() {
return onError;
}
public void setOnError(int onError) {
this.onError = onError;
}
public Scheme withOnError(int onError) {
this.onError = onError;
return this;
}
public int getErrorContainer() {
return errorContainer;
}
public void setErrorContainer(int errorContainer) {
this.errorContainer = errorContainer;
}
public Scheme withErrorContainer(int errorContainer) {
this.errorContainer = errorContainer;
return this;
}
public int getOnErrorContainer() {
return onErrorContainer;
}
public void setOnErrorContainer(int onErrorContainer) {
this.onErrorContainer = onErrorContainer;
}
public Scheme withOnErrorContainer(int onErrorContainer) {
this.onErrorContainer = onErrorContainer;
return this;
}
public int getBackground() {
return background;
}
public void setBackground(int background) {
this.background = background;
}
public Scheme withBackground(int background) {
this.background = background;
return this;
}
public int getOnBackground() {
return onBackground;
}
public void setOnBackground(int onBackground) {
this.onBackground = onBackground;
}
public Scheme withOnBackground(int onBackground) {
this.onBackground = onBackground;
return this;
}
public int getSurface() {
return surface;
}
public void setSurface(int surface) {
this.surface = surface;
}
public Scheme withSurface(int surface) {
this.surface = surface;
return this;
}
public int getOnSurface() {
return onSurface;
}
public void setOnSurface(int onSurface) {
this.onSurface = onSurface;
}
public Scheme withOnSurface(int onSurface) {
this.onSurface = onSurface;
return this;
}
public int getSurfaceVariant() {
return surfaceVariant;
}
public void setSurfaceVariant(int surfaceVariant) {
this.surfaceVariant = surfaceVariant;
}
public Scheme withSurfaceVariant(int surfaceVariant) {
this.surfaceVariant = surfaceVariant;
return this;
}
public int getOnSurfaceVariant() {
return onSurfaceVariant;
}
public void setOnSurfaceVariant(int onSurfaceVariant) {
this.onSurfaceVariant = onSurfaceVariant;
}
public Scheme withOnSurfaceVariant(int onSurfaceVariant) {
this.onSurfaceVariant = onSurfaceVariant;
return this;
}
public int getOutline() {
return outline;
}
public void setOutline(int outline) {
this.outline = outline;
}
public Scheme withOutline(int outline) {
this.outline = outline;
return this;
}
public int getOutlineVariant() {
return outlineVariant;
}
public void setOutlineVariant(int outlineVariant) {
this.outlineVariant = outlineVariant;
}
public Scheme withOutlineVariant(int outlineVariant) {
this.outlineVariant = outlineVariant;
return this;
}
public int getShadow() {
return shadow;
}
public void setShadow(int shadow) {
this.shadow = shadow;
}
public Scheme withShadow(int shadow) {
this.shadow = shadow;
return this;
}
public int getScrim() {
return scrim;
}
public void setScrim(int scrim) {
this.scrim = scrim;
}
public Scheme withScrim(int scrim) {
this.scrim = scrim;
return this;
}
public int getInverseSurface() {
return inverseSurface;
}
public void setInverseSurface(int inverseSurface) {
this.inverseSurface = inverseSurface;
}
public Scheme withInverseSurface(int inverseSurface) {
this.inverseSurface = inverseSurface;
return this;
}
public int getInverseOnSurface() {
return inverseOnSurface;
}
public void setInverseOnSurface(int inverseOnSurface) {
this.inverseOnSurface = inverseOnSurface;
}
public Scheme withInverseOnSurface(int inverseOnSurface) {
this.inverseOnSurface = inverseOnSurface;
return this;
}
public int getInversePrimary() {
return inversePrimary;
}
public void setInversePrimary(int inversePrimary) {
this.inversePrimary = inversePrimary;
}
public Scheme withInversePrimary(int inversePrimary) {
this.inversePrimary = inversePrimary;
return this;
}
public int getSurfaceDim() {
return surfaceDim;
}
public void setSurfaceDim(int surfaceDim) {
this.surfaceDim = surfaceDim;
}
public Scheme withSurfaceDim(int surfaceDim) {
this.surfaceDim = surfaceDim;
return this;
}
public int getSurfaceBright() {
return surfaceBright;
}
public void setSurfaceBright(int surfaceBright) {
this.surfaceBright = surfaceBright;
}
public Scheme withSurfaceBright(int surfaceBright) {
this.surfaceBright = surfaceBright;
return this;
}
public int getSurfaceContainerLowest() {
return surfaceContainerLowest;
}
public void setSurfaceContainerLowest(int surfaceContainerLowest) {
this.surfaceContainerLowest = surfaceContainerLowest;
}
public Scheme withSurfaceContainerLowest(int surfaceContainerLowest) {
this.surfaceContainerLowest = surfaceContainerLowest;
return this;
}
public int getSurfaceContainerLow() {
return surfaceContainerLow;
}
public void setSurfaceContainerLow(int surfaceContainerLow) {
this.surfaceContainerLow = surfaceContainerLow;
}
public Scheme withSurfaceContainerLow(int surfaceContainerLow) {
this.surfaceContainerLow = surfaceContainerLow;
return this;
}
public int getSurfaceContainer() {
return surfaceContainer;
}
public void setSurfaceContainer(int surfaceContainer) {
this.surfaceContainer = surfaceContainer;
}
public Scheme withSurfaceContainer(int surfaceContainer) {
this.surfaceContainer = surfaceContainer;
return this;
}
public int getSurfaceContainerHigh() {
return surfaceContainerHigh;
}
public void setSurfaceContainerHigh(int surfaceContainerHigh) {
this.surfaceContainerHigh = surfaceContainerHigh;
}
public Scheme withSurfaceContainerHigh(int surfaceContainerHigh) {
this.surfaceContainerHigh = surfaceContainerHigh;
return this;
}
public int getSurfaceContainerHighest() {
return surfaceContainerHighest;
}
public void setSurfaceContainerHighest(int surfaceContainerHighest) {
this.surfaceContainerHighest = surfaceContainerHighest;
}
public Scheme withSurfaceContainerHighest(int surfaceContainerHighest) {
this.surfaceContainerHighest = surfaceContainerHighest;
return this;
}
@Override
public String toString() {
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
+ ", surfaceDim="
+ surfaceDim
+ ", surfaceBright="
+ surfaceBright
+ ", surfaceContainerLowest="
+ surfaceContainerLowest
+ ", surfaceContainerLow="
+ surfaceContainerLow
+ ", surfaceContainer="
+ surfaceContainer
+ ", surfaceContainerHigh="
+ surfaceContainerHigh
+ ", surfaceContainerHighest="
+ surfaceContainerHighest
+ '}';
}
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof Scheme)) {
return false;
}
if (!super.equals(object)) {
return false;
}
Scheme scheme = (Scheme) object;
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;
}
if (inversePrimary != scheme.inversePrimary) {
return false;
}
if (surfaceDim != scheme.surfaceDim) {
return false;
}
if (surfaceBright != scheme.surfaceBright) {
return false;
}
if (surfaceContainerLowest != scheme.surfaceContainerLowest) {
return false;
}
if (surfaceContainerLow != scheme.surfaceContainerLow) {
return false;
}
if (surfaceContainer != scheme.surfaceContainer) {
return false;
}
if (surfaceContainerHigh != scheme.surfaceContainerHigh) {
return false;
}
if (surfaceContainerHighest != scheme.surfaceContainerHighest) {
return false;
}
return true;
}
@Override
public int hashCode() {
int 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;
result = 31 * result + surfaceDim;
result = 31 * result + surfaceBright;
result = 31 * result + surfaceContainerLowest;
result = 31 * result + surfaceContainerLow;
result = 31 * result + surfaceContainer;
result = 31 * result + surfaceContainerHigh;
result = 31 * result + surfaceContainerHighest;
return result;
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package scheme
import dynamiccolor.ColorSpec.SpecVersion
import dynamiccolor.ColorSpecs
import dynamiccolor.DynamicScheme
import dynamiccolor.Variant
import hct.Hct
/**
* 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,
specVersion: SpecVersion = DEFAULT_SPEC_VERSION,
platform: Platform = DEFAULT_PLATFORM,
) :
DynamicScheme(
sourceColorHct,
Variant.CONTENT,
isDark,
contrastLevel,
platform,
specVersion,
ColorSpecs.get(specVersion)
.getPrimaryPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getSecondaryPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getTertiaryPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralVariantPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getErrorPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel),
)

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package scheme
import dynamiccolor.ColorSpec.SpecVersion
import dynamiccolor.ColorSpecs
import dynamiccolor.DynamicScheme
import dynamiccolor.Variant
import hct.Hct
/** A playful theme - the source color's hue does not appear in the theme. */
class SchemeExpressive(
sourceColorHct: Hct,
isDark: Boolean,
contrastLevel: Double,
specVersion: SpecVersion = DEFAULT_SPEC_VERSION,
platform: Platform = DEFAULT_PLATFORM,
) :
DynamicScheme(
sourceColorHct,
Variant.EXPRESSIVE,
isDark,
contrastLevel,
platform,
specVersion,
ColorSpecs.get(specVersion)
.getPrimaryPalette(Variant.EXPRESSIVE, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getSecondaryPalette(Variant.EXPRESSIVE, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getTertiaryPalette(Variant.EXPRESSIVE, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralPalette(Variant.EXPRESSIVE, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralVariantPalette(
Variant.EXPRESSIVE,
sourceColorHct,
isDark,
platform,
contrastLevel,
),
ColorSpecs.get(specVersion)
.getErrorPalette(Variant.EXPRESSIVE, sourceColorHct, isDark, platform, contrastLevel),
)

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package scheme
import dynamiccolor.ColorSpec.SpecVersion
import dynamiccolor.ColorSpecs
import dynamiccolor.DynamicScheme
import dynamiccolor.Variant
import hct.Hct
/**
* 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,
specVersion: SpecVersion = DEFAULT_SPEC_VERSION,
platform: Platform = DEFAULT_PLATFORM,
) :
DynamicScheme(
sourceColorHct,
Variant.FIDELITY,
isDark,
contrastLevel,
platform,
specVersion,
ColorSpecs.get(specVersion)
.getPrimaryPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getSecondaryPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getTertiaryPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralVariantPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getErrorPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel),
)

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package scheme
import dynamiccolor.ColorSpec.SpecVersion
import dynamiccolor.ColorSpecs
import dynamiccolor.DynamicScheme
import dynamiccolor.Variant
import hct.Hct
/** A playful theme - the source color's hue does not appear in the theme. */
class SchemeFruitSalad(
sourceColorHct: Hct,
isDark: Boolean,
contrastLevel: Double,
specVersion: SpecVersion = DEFAULT_SPEC_VERSION,
platform: Platform = DEFAULT_PLATFORM,
) :
DynamicScheme(
sourceColorHct,
Variant.FRUIT_SALAD,
isDark,
contrastLevel,
platform,
specVersion,
ColorSpecs.get(specVersion)
.getPrimaryPalette(Variant.FRUIT_SALAD, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getSecondaryPalette(Variant.FRUIT_SALAD, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getTertiaryPalette(Variant.FRUIT_SALAD, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralPalette(Variant.FRUIT_SALAD, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralVariantPalette(
Variant.FRUIT_SALAD,
sourceColorHct,
isDark,
platform,
contrastLevel,
),
ColorSpecs.get(specVersion)
.getErrorPalette(Variant.FRUIT_SALAD, sourceColorHct, isDark, platform, contrastLevel),
)

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package scheme
import dynamiccolor.ColorSpec.SpecVersion
import dynamiccolor.ColorSpecs
import dynamiccolor.DynamicScheme
import dynamiccolor.Variant
import hct.Hct
/** A monochrome theme, colors are purely black / white / gray. */
class SchemeMonochrome(
sourceColorHct: Hct,
isDark: Boolean,
contrastLevel: Double,
specVersion: SpecVersion = DEFAULT_SPEC_VERSION,
platform: Platform = DEFAULT_PLATFORM,
) :
DynamicScheme(
sourceColorHct,
Variant.MONOCHROME,
isDark,
contrastLevel,
platform,
specVersion,
ColorSpecs.get(specVersion)
.getPrimaryPalette(Variant.MONOCHROME, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getSecondaryPalette(Variant.MONOCHROME, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getTertiaryPalette(Variant.MONOCHROME, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralPalette(Variant.MONOCHROME, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralVariantPalette(
Variant.MONOCHROME,
sourceColorHct,
isDark,
platform,
contrastLevel,
),
ColorSpecs.get(specVersion)
.getErrorPalette(Variant.MONOCHROME, sourceColorHct, isDark, platform, contrastLevel),
)

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package scheme
import dynamiccolor.ColorSpec.SpecVersion
import dynamiccolor.ColorSpecs
import dynamiccolor.DynamicScheme
import dynamiccolor.Variant
import hct.Hct
/** A theme that's slightly more chromatic than monochrome, which is purely black / white / gray. */
class SchemeNeutral(
sourceColorHct: Hct,
isDark: Boolean,
contrastLevel: Double,
specVersion: SpecVersion = DEFAULT_SPEC_VERSION,
platform: Platform = DEFAULT_PLATFORM,
) :
DynamicScheme(
sourceColorHct,
Variant.NEUTRAL,
isDark,
contrastLevel,
platform,
specVersion,
ColorSpecs.get(specVersion)
.getPrimaryPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getSecondaryPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getTertiaryPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralVariantPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getErrorPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel),
)

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package scheme
import dynamiccolor.ColorSpec.SpecVersion
import dynamiccolor.ColorSpecs
import dynamiccolor.DynamicScheme
import dynamiccolor.Variant
import hct.Hct
/** A playful theme - the source color's hue does not appear in the theme. */
class SchemeRainbow(
sourceColorHct: Hct,
isDark: Boolean,
contrastLevel: Double,
specVersion: SpecVersion = DEFAULT_SPEC_VERSION,
platform: Platform = DEFAULT_PLATFORM,
) :
DynamicScheme(
sourceColorHct,
Variant.RAINBOW,
isDark,
contrastLevel,
platform,
specVersion,
ColorSpecs.get(specVersion)
.getPrimaryPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getSecondaryPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getTertiaryPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralVariantPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getErrorPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel),
)

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package scheme
import dynamiccolor.ColorSpec.SpecVersion
import dynamiccolor.ColorSpecs
import dynamiccolor.DynamicScheme
import dynamiccolor.Variant
import hct.Hct
/** A calm theme, sedated colors that aren't particularly chromatic. */
class SchemeTonalSpot(
sourceColorHct: Hct,
isDark: Boolean,
contrastLevel: Double,
specVersion: SpecVersion = DEFAULT_SPEC_VERSION,
platform: Platform = DEFAULT_PLATFORM,
) :
DynamicScheme(
sourceColorHct,
Variant.TONAL_SPOT,
isDark,
contrastLevel,
platform,
specVersion,
ColorSpecs.get(specVersion)
.getPrimaryPalette(Variant.TONAL_SPOT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getSecondaryPalette(Variant.TONAL_SPOT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getTertiaryPalette(Variant.TONAL_SPOT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralPalette(Variant.TONAL_SPOT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralVariantPalette(
Variant.TONAL_SPOT,
sourceColorHct,
isDark,
platform,
contrastLevel,
),
ColorSpecs.get(specVersion)
.getErrorPalette(Variant.TONAL_SPOT, sourceColorHct, isDark, platform, contrastLevel),
)

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package scheme
import dynamiccolor.ColorSpec.SpecVersion
import dynamiccolor.ColorSpecs
import dynamiccolor.DynamicScheme
import dynamiccolor.Variant
import hct.Hct
/** A loud theme, colorfulness is maximum for Primary palette, increased for others. */
class SchemeVibrant(
sourceColorHct: Hct,
isDark: Boolean,
contrastLevel: Double,
specVersion: SpecVersion = DEFAULT_SPEC_VERSION,
platform: Platform = DEFAULT_PLATFORM,
) :
DynamicScheme(
sourceColorHct,
Variant.VIBRANT,
isDark,
contrastLevel,
platform,
specVersion,
ColorSpecs.get(specVersion)
.getPrimaryPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getSecondaryPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getTertiaryPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getNeutralVariantPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel),
ColorSpecs.get(specVersion)
.getErrorPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel),
)

View File

@@ -0,0 +1,164 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package score
import hct.Hct
import utils.MathUtils
import java.util.ArrayList
import java.util.Collections
import kotlin.math.floor
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 TARGET_CHROMA = 48.0 // A1 Chroma
private const val WEIGHT_PROPORTION = 0.7
private const val WEIGHT_CHROMA_ABOVE = 0.3
private const val WEIGHT_CHROMA_BELOW = 0.1
private const val CUTOFF_CHROMA = 5.0
private const val CUTOFF_EXCITED_PROPORTION = 0.01
@JvmStatic
fun score(colorsToPopulation: Map<Int, Int>): List<Int> {
// Fallback color is Google Blue.
return score(colorsToPopulation, 4, 0xff4285f4.toInt(), true)
}
@JvmStatic
fun score(colorsToPopulation: Map<Int, Int>, desired: Int): List<Int> {
return score(colorsToPopulation, desired, 0xff4285f4.toInt(), true)
}
@JvmStatic
fun score(colorsToPopulation: Map<Int, Int>, desired: Int, fallbackColorArgb: Int): List<Int> {
return score(colorsToPopulation, desired, fallbackColorArgb, true)
}
/**
* 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.
* @param desired max count of colors to be returned in the list.
* @param fallbackColorArgb color to be returned if no other options available.
* @param filter whether to filter out undesireable combinations.
* @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.
*/
@JvmStatic
fun score(
colorsToPopulation: Map<Int, Int>,
desired: Int,
fallbackColorArgb: Int,
filter: Boolean,
): List<Int> {
// Get the HCT color for each Argb value, while finding the per hue count and
// total count.
val colorsHct: MutableList<Hct> = ArrayList()
val huePopulation = IntArray(360)
var populationSum = 0.0
for ((key, value) in colorsToPopulation) {
val hct = Hct.fromInt(key)
colorsHct.add(hct)
val hue = floor(hct.hue).toInt()
huePopulation[hue] += value
populationSum += value.toDouble()
}
// Hues with more usage in neighboring 30 degree slice get a larger number.
val hueExcitedProportions = DoubleArray(360)
for (hue in 0..359) {
val proportion = huePopulation[hue] / populationSum
for (i in hue - 14 until hue + 16) {
val neighborHue = MathUtils.sanitizeDegreesInt(i)
hueExcitedProportions[neighborHue] += proportion
}
}
// Scores each HCT color based on usage and chroma, while optionally
// filtering out values that do not have enough chroma or usage.
val scoredHcts: MutableList<ScoredHCT> = ArrayList()
for (hct in colorsHct) {
val hue = MathUtils.sanitizeDegreesInt(hct.hue.roundToInt())
val proportion = hueExcitedProportions[hue]
if (filter && (hct.chroma < CUTOFF_CHROMA || proportion <= CUTOFF_EXCITED_PROPORTION)) {
continue
}
val proportionScore = proportion * 100.0 * WEIGHT_PROPORTION
val chromaWeight =
if (hct.chroma < TARGET_CHROMA) WEIGHT_CHROMA_BELOW else WEIGHT_CHROMA_ABOVE
val chromaScore = (hct.chroma - TARGET_CHROMA) * chromaWeight
val score = proportionScore + chromaScore
scoredHcts.add(ScoredHCT(hct, score))
}
// Sorted so that colors with higher scores come first.
Collections.sort(scoredHcts, ScoredComparator())
// Iterates through potential hue differences in degrees in order to select
// the colors with the largest distribution of hues possible. Starting at
// 90 degrees(maximum difference for 4 colors) then decreasing down to a
// 15 degree minimum.
val chosenColors: MutableList<Hct> = ArrayList()
for (differenceDegrees in 90 downTo 15) {
chosenColors.clear()
for (entry in scoredHcts) {
val hct = entry.hct
var hasDuplicateHue = false
for (chosenHct in chosenColors) {
if (MathUtils.differenceDegrees(hct.hue, chosenHct.hue) < differenceDegrees) {
hasDuplicateHue = true
break
}
}
if (!hasDuplicateHue) {
chosenColors.add(hct)
}
if (chosenColors.size >= desired) {
break
}
}
if (chosenColors.size >= desired) {
break
}
}
val colors: MutableList<Int> = ArrayList()
if (chosenColors.isEmpty()) {
colors.add(fallbackColorArgb)
}
for (chosenHct in chosenColors) {
colors.add(chosenHct.toInt())
}
return colors
}
private class ScoredHCT(val hct: Hct, val score: Double)
private class ScoredComparator() : Comparator<ScoredHCT> {
override fun compare(entry1: ScoredHCT, entry2: ScoredHCT): Int {
return entry2.score.compareTo(entry1.score)
}
}
}

View File

@@ -0,0 +1,302 @@
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package temperature
import hct.Hct
import utils.ColorUtils
import utils.MathUtils
import java.util.ArrayList
import java.util.Collections
import java.util.HashMap
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.roundToInt
/**
* Design utilities using color temperature theory.
*
* Analogous colors, complementary color, and cache to efficiently, lazily, generate data for
* calculations when needed.
*/
class TemperatureCache(val input: Hct) {
private var _precomputedComplement: Hct? = null
private var _precomputedHctsByTemp: List<Hct>? = null
private var _precomputedHctsByHue: List<Hct>? = null
private var _precomputedTempsByHct: Map<Hct, Double>? = null
/**
* 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.
*/
val complement: Hct
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 = 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 = MathUtils.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!!
}
/**
* 5 colors that pair well with the input color.
*
* The colors are equidistant in temperature and adjacent in hue.
*/
fun getAnalogousColors(): List<Hct> {
return 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<Hct> {
// The starting hue is the hue of the input color.
val startHue = input.hue.roundToInt()
val startHct = hctsByHue[startHue]
var lastTemp = getRelativeTemperature(startHct)
val allColors: MutableList<Hct> = ArrayList()
allColors.add(startHct)
var absoluteTotalTempDelta = 0f
for (i in 0..359) {
val hue = MathUtils.sanitizeDegreesInt(startHue + i)
val hct = hctsByHue[hue]
val temp = getRelativeTemperature(hct)
val tempDelta = abs(temp - lastTemp)
lastTemp = temp
absoluteTotalTempDelta += tempDelta.toFloat()
}
var hueAddend = 1
val tempStep = absoluteTotalTempDelta / divisions.toDouble()
var totalTempDelta = 0.0
lastTemp = getRelativeTemperature(startHct)
while (allColors.size < divisions) {
val hue = MathUtils.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<Hct> = ArrayList()
answers.add(input)
val ccwCount = floor(((count - 1.0) / 2.0)).toInt()
for (i in 1 until ccwCount + 1) {
var index = 0 - i
while (index < 0) {
index = allColors.size + index
}
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 + index
}
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
}
}
/** Coldest color with same chroma and tone as input. */
private val coldest: Hct
get() = hctsByTemp[0]
/**
* HCTs for all colors with the same chroma/tone as the input.
*
* Sorted by hue, ex. index 0 is hue 0.
*/
private val hctsByHue: List<Hct>
get() {
if (_precomputedHctsByHue != null) {
return _precomputedHctsByHue!!
}
val hcts: MutableList<Hct> = ArrayList()
var hue = 0.0
while (hue <= 360.0) {
val colorAtHue = Hct.from(hue, input.chroma, input.tone)
hcts.add(colorAtHue)
hue += 1.0
}
val unmodifiableList = Collections.unmodifiableList(hcts)
_precomputedHctsByHue = unmodifiableList
return unmodifiableList
}
/**
* HCTs for all colors with the same chroma/tone as the input.
*
* Sorted from coldest first to warmest last.
*/
private val hctsByTemp: List<Hct>
get() {
if (_precomputedHctsByTemp != null) {
return _precomputedHctsByTemp!!
}
val hcts: MutableList<Hct> = ArrayList(hctsByHue)
hcts.add(input)
hcts.sortWith(compareBy { tempsByHct[it]!! })
_precomputedHctsByTemp = hcts
return hcts
}
/** Keys of HCTs in getHctsByTemp, values of raw temperature. */
private val tempsByHct: Map<Hct, Double>
get() {
if (_precomputedTempsByHct != null) {
return _precomputedTempsByHct!!
}
val allHcts: MutableList<Hct> = ArrayList(hctsByHue)
allHcts.add(input)
val temperaturesByHct: MutableMap<Hct, Double> = HashMap()
for (hct in allHcts) {
temperaturesByHct[hct] = rawTemperature(hct)
}
_precomputedTempsByHct = temperaturesByHct
return temperaturesByHct
}
/** Warmest color with same chroma and tone as input. */
private val warmest: Hct
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 = ColorUtils.labFromArgb(color.toInt())
val hue = MathUtils.sanitizeDegreesDouble(Math.toDegrees(atan2(lab[2], lab[1])))
val chroma = hypot(lab[1], lab[2])
return -0.5 +
0.02 * chroma.pow(1.07) * cos(Math.toRadians(MathUtils.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) {
a <= angle && angle <= b
} else {
a <= angle || angle <= b
}
}
}
}

View File

@@ -1,267 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This file is automatically generated. Do not modify it.
package utils;
/**
* Color science utilities.
*
* <p>Utility methods for color science constants and color space conversions that aren't HCT or
* CAM16.
*/
public class ColorUtils {
private ColorUtils() {}
static final double[][] SRGB_TO_XYZ =
new double[][] {
new double[] {0.41233895, 0.35762064, 0.18051042},
new double[] {0.2126, 0.7152, 0.0722},
new double[] {0.01932141, 0.11916382, 0.95034478},
};
static final double[][] XYZ_TO_SRGB =
new double[][] {
new double[] {
3.2413774792388685, -1.5376652402851851, -0.49885366846268053,
},
new double[] {
-0.9691452513005321, 1.8758853451067872, 0.04156585616912061,
},
new double[] {
0.05562093689691305, -0.20395524564742123, 1.0571799111220335,
},
};
static final double[] WHITE_POINT_D65 = new double[] {95.047, 100.0, 108.883};
/** Converts a color from RGB components to ARGB format. */
public static int argbFromRgb(int red, int green, int blue) {
return (255 << 24) | ((red & 255) << 16) | ((green & 255) << 8) | (blue & 255);
}
/** Converts a color from linear RGB components to ARGB format. */
public static int argbFromLinrgb(double[] linrgb) {
int r = delinearized(linrgb[0]);
int g = delinearized(linrgb[1]);
int b = delinearized(linrgb[2]);
return argbFromRgb(r, g, b);
}
/** Returns the alpha component of a color in ARGB format. */
public static int alphaFromArgb(int argb) {
return (argb >> 24) & 255;
}
/** Returns the red component of a color in ARGB format. */
public static int redFromArgb(int argb) {
return (argb >> 16) & 255;
}
/** Returns the green component of a color in ARGB format. */
public static int greenFromArgb(int argb) {
return (argb >> 8) & 255;
}
/** Returns the blue component of a color in ARGB format. */
public static int blueFromArgb(int argb) {
return argb & 255;
}
/** Returns whether a color in ARGB format is opaque. */
public static boolean isOpaque(int argb) {
return alphaFromArgb(argb) >= 255;
}
/** Converts a color from ARGB to XYZ. */
public static int argbFromXyz(double x, double y, double z) {
double[][] matrix = XYZ_TO_SRGB;
double linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z;
double linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z;
double linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z;
int r = delinearized(linearR);
int g = delinearized(linearG);
int b = delinearized(linearB);
return argbFromRgb(r, g, b);
}
/** Converts a color from XYZ to ARGB. */
public static double[] xyzFromArgb(int argb) {
double r = linearized(redFromArgb(argb));
double g = linearized(greenFromArgb(argb));
double b = linearized(blueFromArgb(argb));
return MathUtils.matrixMultiply(new double[] {r, g, b}, SRGB_TO_XYZ);
}
/** Converts a color represented in Lab color space into an ARGB integer. */
public static int argbFromLab(double l, double a, double b) {
double[] whitePoint = WHITE_POINT_D65;
double fy = (l + 16.0) / 116.0;
double fx = a / 500.0 + fy;
double fz = fy - b / 200.0;
double xNormalized = labInvf(fx);
double yNormalized = labInvf(fy);
double zNormalized = labInvf(fz);
double x = xNormalized * whitePoint[0];
double y = yNormalized * whitePoint[1];
double 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
*/
public static double[] labFromArgb(int argb) {
double linearR = linearized(redFromArgb(argb));
double linearG = linearized(greenFromArgb(argb));
double linearB = linearized(blueFromArgb(argb));
double[][] matrix = SRGB_TO_XYZ;
double x = matrix[0][0] * linearR + matrix[0][1] * linearG + matrix[0][2] * linearB;
double y = matrix[1][0] * linearR + matrix[1][1] * linearG + matrix[1][2] * linearB;
double z = matrix[2][0] * linearR + matrix[2][1] * linearG + matrix[2][2] * linearB;
double[] whitePoint = WHITE_POINT_D65;
double xNormalized = x / whitePoint[0];
double yNormalized = y / whitePoint[1];
double zNormalized = z / whitePoint[2];
double fx = labF(xNormalized);
double fy = labF(yNormalized);
double fz = labF(zNormalized);
double l = 116.0 * fy - 16;
double a = 500.0 * (fx - fy);
double b = 200.0 * (fy - fz);
return new double[] {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*
*/
public static int argbFromLstar(double lstar) {
double y = yFromLstar(lstar);
int 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
*/
public static double lstarFromArgb(int argb) {
double y = xyzFromArgb(argb)[1];
return 116.0 * labF(y / 100.0) - 16.0;
}
/**
* Converts an L* value to a Y value.
*
* <p>L* in L*a*b* and Y in XYZ measure the same quantity, luminance.
*
* <p>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
*/
public static double yFromLstar(double lstar) {
return 100.0 * labInvf((lstar + 16.0) / 116.0);
}
/**
* Converts a Y value to an L* value.
*
* <p>L* in L*a*b* and Y in XYZ measure the same quantity, luminance.
*
* <p>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*
*/
public static double lstarFromY(double y) {
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
*/
public static double linearized(int rgbComponent) {
double normalized = rgbComponent / 255.0;
if (normalized <= 0.040449936) {
return normalized / 12.92 * 100.0;
} else {
return Math.pow((normalized + 0.055) / 1.055, 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
*/
public static int delinearized(double rgbComponent) {
double normalized = rgbComponent / 100.0;
double delinearized = 0.0;
if (normalized <= 0.0031308) {
delinearized = normalized * 12.92;
} else {
delinearized = 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055;
}
return MathUtils.clampInt(0, 255, (int) Math.round(delinearized * 255.0));
}
/**
* Returns the standard white point; white on a sunny day.
*
* @return The white point
*/
public static double[] whitePointD65() {
return WHITE_POINT_D65;
}
static double labF(double t) {
double e = 216.0 / 24389.0;
double kappa = 24389.0 / 27.0;
if (t > e) {
return Math.pow(t, 1.0 / 3.0);
} else {
return (kappa * t + 16) / 116;
}
}
static double labInvf(double ft) {
double e = 216.0 / 24389.0;
double kappa = 24389.0 / 27.0;
double ft3 = ft * ft * ft;
if (ft3 > e) {
return ft3;
} else {
return (116 * ft - 16) / kappa;
}
}
}

View File

@@ -0,0 +1,256 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This file is automatically generated. Do not modify it.
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 {
private 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),
)
private 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),
)
private 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
val delinearized: Double =
if (normalized <= 0.0031308) {
normalized * 12.92
} else {
1.055 * normalized.pow(1.0 / 2.4) - 0.055
}
return (delinearized * 255.0).roundToInt().coerceIn(0, 255)
}
/**
* 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
}
}
}

View File

@@ -1,134 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This file is automatically generated. Do not modify it.
package utils;
/** Utility methods for mathematical operations. */
public class MathUtils {
private MathUtils() {}
/**
* The signum function.
*
* @return 1 if num > 0, -1 if num < 0, and 0 if num = 0
*/
public static int signum(double num) {
if (num < 0) {
return -1;
} else if (num == 0) {
return 0;
} else {
return 1;
}
}
/**
* The linear interpolation function.
*
* @return start if amount = 0 and stop if amount = 1
*/
public static double lerp(double start, double stop, double amount) {
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.
*/
public static int clampInt(int min, int max, int input) {
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.
*/
public static double clampDouble(double min, double max, double input) {
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).
*/
public static int sanitizeDegreesInt(int 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).
*/
public static double sanitizeDegreesDouble(double 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.
*
* <p>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.
*/
public static double rotationDirection(double from, double to) {
double increasingDifference = sanitizeDegreesDouble(to - from);
return increasingDifference <= 180.0 ? 1.0 : -1.0;
}
/** Distance of two points on a circle, represented using degrees. */
public static double differenceDegrees(double a, double b) {
return 180.0 - Math.abs(Math.abs(a - b) - 180.0);
}
/** Multiplies a 1x3 row vector with a 3x3 matrix. */
public static double[] matrixMultiply(double[] row, double[][] matrix) {
double a = row[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2];
double b = row[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2];
double c = row[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2];
return new double[] {a, b, c};
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This file is automatically generated. Do not modify it.
package utils
import kotlin.math.abs
/** Utility methods for mathematical operations. */
object MathUtils {
/**
* 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
}
/**
* 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 % 360
if (degrees < 0) {
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 % 360.0
if (degrees < 0) {
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>): 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)
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package utils
/** Utility methods for string representations of colors. */
internal object StringUtils {
/**
* Hex string representing color, ex. #ff0000 for red.
*
* @param argb ARGB representation of a color.
*/
fun hexFromArgb(argb: Int): String {
val red = ColorUtils.redFromArgb(argb)
val blue = ColorUtils.blueFromArgb(argb)
val green = ColorUtils.greenFromArgb(argb)
return String.format("#%02x%02x%02x", red, green, blue)
}
}