Skip to content

Commit

Permalink
Feature: Calculate theme colors from an ImageBitmap (#95)
Browse files Browse the repository at this point in the history
* add ability to calculate ui colors from an image

* update documentation
  • Loading branch information
jordond authored Feb 2, 2024
1 parent b804034 commit ce38e2a
Show file tree
Hide file tree
Showing 7 changed files with 243 additions and 46 deletions.
103 changes: 68 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ The KDoc is published at [docs.materialkolor.com](docs.materialkolor.com)

- [Platforms](#platforms)
- [Inspiration](#inspiration)
- [Generating from an Image](#generating-from-an-image)
- [Setup](#setup)
- [Multiplatform](#multiplatform)
- [Single Platform](#single-platform)
- [Version Catalog](#version-catalog)
- [Multiplatform](#multiplatform)
- [Single Platform](#single-platform)
- [Version Catalog](#version-catalog)
- [Usage](#usage)
- [Generating from an Image](#generating-from-an-image)
- [Advanced](#advanced)
- [Demo](#demo)
- [License](#license)
- [Changes from original source](#changes-from-original-source)
Expand All @@ -56,37 +57,6 @@ code was taken and converted into a Kotlin Multiplatform library.
I also incorporated the Compose ideas from another open source
library [m3color](https://github.com/Kyant0/m3color).

### Generating from an Image

You can now generate a dynamic color scheme by using my
library [kmPalette](https://github.com/jordond/kmpalette).

You can get the dominant color from an image, or you can also generate a color palette.

Follow the instructions there to set it up, then as an example. You can use it to generate a color
theme from a remote image:

```kotlin
@Composable
fun SampleTheme(
imageUrl: Url, // Url("http://example.com/image.jpg")
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val networkLoader = rememberNetworkLoader()
val dominantColorState = rememberDominantColorState(loader = networkLoader)
LaunchedEffect(imageUrl) {
dominantColorState.updateFrom(imageUrl)
}

AnimatedDynamicMaterialTheme(
seedColor = dominantColorState.color,
isDark = useDarkTheme,
content = content
)
}
```

## Setup

You can add this library to your project using Gradle.
Expand Down Expand Up @@ -191,6 +161,69 @@ Also included is a `AnimatedDynamicMaterialTheme` which animates the color schem
See [`Theme.kt`](demo/composeApp/src/commonMain/kotlin/com/materialkolor/demo/theme/Theme.kt) for an
example.

## Generating from an Image

You can calculate a seed color, or colors that are suitable for UI theming from an image. This is
useful for generating a color scheme from a user's profile picture, or a background image.

To do so you can call `ImageBitmap.themeColors()`, `ImageBitmap.themeColor()` or the `@Composable`
function `rememberThemeColors()` or `rememberThemeColor()`:

```kotlin
fun calculateSeedColor(bitmap: ImageBitmap): Color {
val suitableColors = bitmap.themeColors(fallback = Color.Blue)
return suitableColors.first()
}
```

See [`ImageBitmap.kt`](material-kolor/src/commonMain/kotlin/com/materialkolor/ktx/ImageBitmap.kt)
for more information.

Or in Compose land:

```kotlin
@Composable
fun DynamicTheme(image: ImageBitmap, content: @Composable () -> Unit) {
val seedColor = rememberThemeColor(image, fallback = MaterialTheme.colorScheme.primary)

AnimatedDynamicMaterialTheme(
seedColor = seedColor,
content = content
)
}
```

### Advanced

For a more advanced use-case you can use my other
library [kmPalette](https://github.com/jordond/kmpalette).

You can get the dominant color from an image, or you can also generate a color palette.

Follow the instructions there to set it up, then as an example. You can use it to generate a color
theme from a remote image:

```kotlin
@Composable
fun SampleTheme(
imageUrl: Url, // Url("http://example.com/image.jpg")
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val networkLoader = rememberNetworkLoader()
val dominantColorState = rememberDominantColorState(loader = networkLoader)
LaunchedEffect(imageUrl) {
dominantColorState.updateFrom(imageUrl)
}

AnimatedDynamicMaterialTheme(
seedColor = dominantColorState.color,
isDark = useDarkTheme,
content = content
)
}
```

## Demo

A demo app is available in the `demo` directory. It is a Compose Multiplatform app that runs on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ package com.materialkolor.quantize
* 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
*/
internal object QuantizerCelebi {
object QuantizerCelebi {

/**
* Reduce the number of colors needed to represented the input, minimizing the difference between
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,9 @@ internal class QuantizerWu : Quantizer {
}

class MaximizeResult internal constructor(
// < 0 if cut impossible
var cutLocation: Int, var maximum: Double,
// < 0 if cut impossible
var cutLocation: Int,
var maximum: Double,
)

class CreateBoxesResult internal constructor(var requestedCount: Int, var resultCount: Int)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import kotlin.math.round
* muddied, while curating the high cluster count to a much smaller number of appropriate choices.
*/
@Suppress("unused")
internal object Score {
object Score {

private const val TARGET_CHROMA = 48.0 // A1 Chroma
private const val WEIGHT_PROPORTION = 0.7
Expand All @@ -53,19 +53,17 @@ internal object Score {
* Google Blue.
*/
fun score(
colorsToPopulation: Map<Int?, Int>,
colorsToPopulation: Map<Int, Int>,
desired: Int = 4,
fallbackColorArgb: Int = -0xbd7a0c,
fallbackColorArgb: Int? = -0xbd7a0c,
filter: Boolean = true,
): List<Int> {

// Get the HCT color for each Argb value, while finding the per hue count and
// total count.
// Get the HCT color for each Argb value, while finding the per hue count and total count.
val colorsHct: MutableList<Hct> = mutableListOf()
val huePopulation = IntArray(360)
var populationSum = 0.0
for ((key, value) in colorsToPopulation) {
val hct = Hct.fromInt(key!!)
val hct = Hct.fromInt(key)
colorsHct.add(hct)
val hue: Int = floor(hct.hue).toInt()
huePopulation[hue] += value
Expand Down Expand Up @@ -128,7 +126,7 @@ internal object Score {
}
}
val colors: MutableList<Int> = mutableListOf()
if (chosenColors.isEmpty()) {
if (chosenColors.isEmpty() && fallbackColorArgb != null) {
colors.add(fallbackColorArgb)
}
for (chosenHct in chosenColors) {
Expand All @@ -138,6 +136,7 @@ internal object Score {
}

private class ScoredHCT(val hct: Hct, val score: Double)

private class ScoredComparator : Comparator<ScoredHCT> {

override fun compare(a: ScoredHCT, b: ScoredHCT): Int {
Expand Down
23 changes: 23 additions & 0 deletions material-kolor/api/android/material-kolor.api
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public final class com/materialkolor/ktx/ColorKt {
public static final fun isLight-8_81llA (J)Z
}

public final class com/materialkolor/ktx/CorePaletteKt {
public static final fun contentOf-4WTKRHQ (Lcom/materialkolor/palettes/CorePalette$Companion;J)Lcom/materialkolor/palettes/CorePalette;
public static final fun of-4WTKRHQ (Lcom/materialkolor/palettes/CorePalette$Companion;J)Lcom/materialkolor/palettes/CorePalette;
}

public final class com/materialkolor/ktx/DynamicSchemeKt {
public static final fun getSourceColor (Lcom/materialkolor/scheme/DynamicScheme;)J
public static final fun toDynamicScheme-Iv8Zu3U (JZLcom/materialkolor/PaletteStyle;D)Lcom/materialkolor/scheme/DynamicScheme;
Expand All @@ -53,6 +58,24 @@ public final class com/materialkolor/ktx/HctKt {
public static final fun toHct-8_81llA (J)Lcom/materialkolor/hct/Hct;
}

public final class com/materialkolor/ktx/ImageBitmapKt {
public static final fun quantize (Lcom/materialkolor/quantize/QuantizerCelebi;Landroidx/compose/ui/graphics/ImageBitmap;I)Ljava/util/Map;
public static final fun rememberThemeColor-3IgeMak (Landroidx/compose/ui/graphics/ImageBitmap;JZLandroidx/compose/runtime/Composer;II)J
public static final fun rememberThemeColors-sW7UJKQ (Landroidx/compose/ui/graphics/ImageBitmap;JIZLandroidx/compose/runtime/Composer;II)Ljava/util/List;
public static final fun themeColor-bw27NRU (Landroidx/compose/ui/graphics/ImageBitmap;JZ)J
public static synthetic fun themeColor-bw27NRU$default (Landroidx/compose/ui/graphics/ImageBitmap;JZILjava/lang/Object;)J
public static final fun themeColorOrNull (Landroidx/compose/ui/graphics/ImageBitmap;Z)Landroidx/compose/ui/graphics/Color;
public static synthetic fun themeColorOrNull$default (Landroidx/compose/ui/graphics/ImageBitmap;ZILjava/lang/Object;)Landroidx/compose/ui/graphics/Color;
public static final fun themeColors-9LQNqLg (Landroidx/compose/ui/graphics/ImageBitmap;IJZ)Ljava/util/List;
public static synthetic fun themeColors-9LQNqLg$default (Landroidx/compose/ui/graphics/ImageBitmap;IJZILjava/lang/Object;)Ljava/util/List;
}

public final class com/materialkolor/ktx/TemperatureKt {
public static final fun isCool-8_81llA (J)Z
public static final fun isWarm-8_81llA (J)Z
public static final fun temperature-4WTKRHQ (Lcom/materialkolor/temperature/TemperatureCache$Companion;J)D
}

public final class com/materialkolor/ktx/TonalPaletteKt {
public static final fun fromColor-4WTKRHQ (Lcom/materialkolor/palettes/TonalPalette$Companion;J)Lcom/materialkolor/palettes/TonalPalette;
}
Expand Down
23 changes: 23 additions & 0 deletions material-kolor/api/jvm/material-kolor.api
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public final class com/materialkolor/ktx/ColorKt {
public static final fun isLight-8_81llA (J)Z
}

public final class com/materialkolor/ktx/CorePaletteKt {
public static final fun contentOf-4WTKRHQ (Lcom/materialkolor/palettes/CorePalette$Companion;J)Lcom/materialkolor/palettes/CorePalette;
public static final fun of-4WTKRHQ (Lcom/materialkolor/palettes/CorePalette$Companion;J)Lcom/materialkolor/palettes/CorePalette;
}

public final class com/materialkolor/ktx/DynamicSchemeKt {
public static final fun getSourceColor (Lcom/materialkolor/scheme/DynamicScheme;)J
public static final fun toDynamicScheme-Iv8Zu3U (JZLcom/materialkolor/PaletteStyle;D)Lcom/materialkolor/scheme/DynamicScheme;
Expand All @@ -53,6 +58,24 @@ public final class com/materialkolor/ktx/HctKt {
public static final fun toHct-8_81llA (J)Lcom/materialkolor/hct/Hct;
}

public final class com/materialkolor/ktx/ImageBitmapKt {
public static final fun quantize (Lcom/materialkolor/quantize/QuantizerCelebi;Landroidx/compose/ui/graphics/ImageBitmap;I)Ljava/util/Map;
public static final fun rememberThemeColor-3IgeMak (Landroidx/compose/ui/graphics/ImageBitmap;JZLandroidx/compose/runtime/Composer;II)J
public static final fun rememberThemeColors-sW7UJKQ (Landroidx/compose/ui/graphics/ImageBitmap;JIZLandroidx/compose/runtime/Composer;II)Ljava/util/List;
public static final fun themeColor-bw27NRU (Landroidx/compose/ui/graphics/ImageBitmap;JZ)J
public static synthetic fun themeColor-bw27NRU$default (Landroidx/compose/ui/graphics/ImageBitmap;JZILjava/lang/Object;)J
public static final fun themeColorOrNull (Landroidx/compose/ui/graphics/ImageBitmap;Z)Landroidx/compose/ui/graphics/Color;
public static synthetic fun themeColorOrNull$default (Landroidx/compose/ui/graphics/ImageBitmap;ZILjava/lang/Object;)Landroidx/compose/ui/graphics/Color;
public static final fun themeColors-9LQNqLg (Landroidx/compose/ui/graphics/ImageBitmap;IJZ)Ljava/util/List;
public static synthetic fun themeColors-9LQNqLg$default (Landroidx/compose/ui/graphics/ImageBitmap;IJZILjava/lang/Object;)Ljava/util/List;
}

public final class com/materialkolor/ktx/TemperatureKt {
public static final fun isCool-8_81llA (J)Z
public static final fun isWarm-8_81llA (J)Z
public static final fun temperature-4WTKRHQ (Lcom/materialkolor/temperature/TemperatureCache$Companion;J)D
}

public final class com/materialkolor/ktx/TonalPaletteKt {
public static final fun fromColor-4WTKRHQ (Lcom/materialkolor/palettes/TonalPalette$Companion;J)Lcom/materialkolor/palettes/TonalPalette;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.materialkolor.ktx

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toArgb
import com.materialkolor.quantize.QuantizerCelebi
import com.materialkolor.score.Score

/**
* Quantize the colors in a [ImageBitmap] to a maximum of [maxColors] colors.
*
* @param[image] the [ImageBitmap] to extract colors from.
* @param[maxColors] max count of colors to be returned in the list.
* @return A map of colors to their frequency in the image.
*/
public fun QuantizerCelebi.quantize(image: ImageBitmap, maxColors: Int): Map<Int, Int> {
val pixels = IntArray(image.width * image.height)
image.readPixels(
buffer = pixels,
startX = 0,
startY = 0,
)

return quantize(pixels, maxColors)
}

/**
* Rank the colors in a [ImageBitmap] by their suitability for being used for a UI theme.
*
* @receiver the [ImageBitmap] to extract colors from.
* @param[maxColors]max count of colors to be returned in the list.
* @param[fallback] color to be returned if no other options available.
* @param[filter] whether to filter out undesirable 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.
*/
public fun ImageBitmap.themeColors(
maxColors: Int = 4,
fallback: Color = Color(-0xbd7a0c),
filter: Boolean = true,
): List<Color> {
val quantized = QuantizerCelebi.quantize(image = this, maxColors)
return Score.score(quantized, maxColors, fallback.toArgb(), filter).map { Color(it) }
}

/**
* Determine the most suitable color in a [ImageBitmap] for a UI theme.
*
* @receiver the [ImageBitmap] to extract colors from.
* @param[fallback] color to be returned if no other options available.
* @param[filter] whether to filter out undesirable combinations.
* @return The most suitable color for a UI theme.
*/
public fun ImageBitmap.themeColor(
fallback: Color,
filter: Boolean = true,
): Color {
return themeColors(maxColors = 1, fallback, filter).first()
}

/**
* Determine the most suitable color in a [ImageBitmap] for a UI theme or `null`
*
* @receiver the [ImageBitmap] to extract colors from.
* @param[filter] whether to filter out undesirable combinations.
* @return The most suitable color for a UI theme or `null` if no suitable color found.
*/
public fun ImageBitmap.themeColorOrNull(filter: Boolean = true): Color? {
val quantized = QuantizerCelebi.quantize(image = this, maxColors = 1)
return Score
.score(quantized, desired = 1, fallbackColorArgb = null, filter)
.firstOrNull()
?.let { Color(it) }
}

/**
* Determine the most suitable color in a [ImageBitmap] for a UI theme.
*
* @param[image] the [ImageBitmap] to extract colors from.
* @param[fallback] color to be returned if no other options available. Defaults to primary color.
* @param[filter] whether to filter out undesirable combinations.
* @return The most suitable colors for a UI theme.
*/
@Composable
public fun rememberThemeColors(
image: ImageBitmap,
fallback: Color = MaterialTheme.colorScheme.primary,
maxColors: Int = 4,
filter: Boolean = true,
): List<Color> {
return remember(image, fallback, maxColors, filter) {
image.themeColors(maxColors, fallback, filter)
}
}

/**
* Determine the most suitable color in a [ImageBitmap] for a UI theme.
*
* @param[image] the [ImageBitmap] to extract colors from.
* @param[fallback] color to be returned if no other options available. Defaults to primary color.
* @param[filter] whether to filter out undesirable combinations.
* @return The most suitable color for a UI theme.
*/
@Composable
public fun rememberThemeColor(
image: ImageBitmap,
fallback: Color = MaterialTheme.colorScheme.primary,
filter: Boolean = true,
): Color {
return remember(image, fallback, filter) {
image.themeColor(fallback, filter)
}
}

0 comments on commit ce38e2a

Please sign in to comment.