Skip to content

Commit

Permalink
Optimize create key color algorithm with binary search and caching.
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 636821675
  • Loading branch information
Material Eng authored and copybara-github committed May 24, 2024
1 parent c093082 commit 935c2b4
Show file tree
Hide file tree
Showing 12 changed files with 547 additions and 195 deletions.
89 changes: 57 additions & 32 deletions cpp/palettes/tones.cc
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ TonalPalette::TonalPalette(Argb argb) : key_color_(0.0, 0.0, 0.0) {
Cam cam = CamFromInt(argb);
hue_ = cam.hue;
chroma_ = cam.chroma;
key_color_ = createKeyColor(cam.hue, cam.chroma);
key_color_ = KeyColor(cam.hue, cam.chroma).create();
}

TonalPalette::TonalPalette(Hct hct)
Expand All @@ -40,7 +40,7 @@ TonalPalette::TonalPalette(double hue, double chroma)
: key_color_(hue, chroma, 0.0) {
hue_ = hue;
chroma_ = chroma;
key_color_ = createKeyColor(hue, chroma);
key_color_ = KeyColor(hue, chroma).create();
}

TonalPalette::TonalPalette(double hue, double chroma, Hct key_color)
Expand All @@ -54,38 +54,63 @@ Argb TonalPalette::get(double tone) const {
return IntFromHcl(hue_, chroma_, tone);
}

Hct TonalPalette::createKeyColor(double hue, double chroma) {
double start_tone = 50.0;
Hct smallest_delta_hct(hue, chroma, start_tone);
double smallest_delta = abs(smallest_delta_hct.get_chroma() - 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 (round(chroma) == round(smallest_delta_hct.get_chroma())) {
return smallest_delta_hct;
}
Hct hct_add(hue, chroma, start_tone + delta);
double hct_add_delta = abs(hct_add.get_chroma() - chroma);
if (hct_add_delta < smallest_delta) {
smallest_delta = hct_add_delta;
smallest_delta_hct = hct_add;
}
Hct hct_subtract(hue, chroma, start_tone - delta);
double hct_subtract_delta = abs(hct_subtract.get_chroma() - chroma);
if (hct_subtract_delta < smallest_delta) {
smallest_delta = hct_subtract_delta;
smallest_delta_hct = hct_subtract;
KeyColor::KeyColor(double hue, double requested_chroma)
: hue_(hue), requested_chroma_(requested_chroma) {}

Hct KeyColor::create() {
// Pivot around T50 because T50 has the most chroma available, on
// average. Thus it is most likely to have a direct answer.
const int pivot_tone = 50;
const int tone_step_size = 1;
// Epsilon to accept values slightly higher than the requested chroma.
const double epsilon = 0.01;

// Binary search to find the tone that can provide a chroma that is closest
// to the requested chroma.
int lower_tone = 0;
int upper_tone = 100;
while (lower_tone < upper_tone) {
const int mid_tone = (lower_tone + upper_tone) / 2;
bool is_ascending =
max_chroma(mid_tone) < max_chroma(mid_tone + tone_step_size);
bool sufficient_chroma =
max_chroma(mid_tone) >= requested_chroma_ - epsilon;

if (sufficient_chroma) {
// Either range [lower_tone, mid_tone] or [mid_tone, upper_tone] has
// the answer, so search in the range that is closer the pivot tone.
if (abs(lower_tone - pivot_tone) < abs(upper_tone - pivot_tone)) {
upper_tone = mid_tone;
} else {
if (lower_tone == mid_tone) {
return Hct(hue_, requested_chroma_, lower_tone);
}
lower_tone = mid_tone;
}
} else {
// As there's no sufficient chroma in the mid_tone, follow the direction
// to the chroma peak.
if (is_ascending) {
lower_tone = mid_tone + tone_step_size;
} else {
// Keep mid_tone for potential chroma peak.
upper_tone = mid_tone;
}
}
}
return smallest_delta_hct;

return Hct(hue_, requested_chroma_, lower_tone);
}

double KeyColor::max_chroma(double tone) {
auto it = chroma_cache_.find(tone);
if (it != chroma_cache_.end()) {
return it->second;
}

double chroma = Hct(hue_, max_chroma_value_, tone).get_chroma();
chroma_cache_[tone] = chroma;
return chroma;
};

} // namespace material_color_utilities
27 changes: 26 additions & 1 deletion cpp/palettes/tones.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
#ifndef CPP_PALETTES_TONES_H_
#define CPP_PALETTES_TONES_H_

#include <unordered_map>

#include "cpp/cam/hct.h"
#include "cpp/utils/utils.h"

Expand Down Expand Up @@ -45,8 +47,31 @@ class TonalPalette {
double hue_;
double chroma_;
Hct key_color_;
};

/**
* Key color is a color that represents the hue and chroma of a tonal palette
*/
class KeyColor {
public:
KeyColor(double hue, double requested_chroma);
/**
* 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 in Hct.
*/
Hct create();

private:
const double max_chroma_value_ = 200.0;
double hue_;
double requested_chroma_;
// Cache that maps tone to max chroma to avoid duplicated HCT calculation.
std::unordered_map<double, double> chroma_cache_;

Hct createKeyColor(double hue, double chroma);
double max_chroma(double tone);
};

} // namespace material_color_utilities
Expand Down
41 changes: 41 additions & 0 deletions cpp/palettes/tones_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include "cpp/palettes/tones.h"

#include "testing/base/public/gunit.h"
#include "cpp/cam/hct.h"
#include "cpp/utils/utils.h"

namespace material_color_utilities {
Expand All @@ -40,5 +41,45 @@ TEST(TonesTest, Blue) {
EXPECT_EQ(HexFromArgb(tonal_palette.get(0)), "ff000000");
}

TEST(KeyColorTests, ExactChromaAvailable) {
// Requested chroma is exactly achievable at a certain tone.
TonalPalette palette = TonalPalette(50.0, 60.0);
Hct result = palette.get_key_color();

EXPECT_NEAR(result.get_hue(), 50.0, 10.0);
EXPECT_NEAR(result.get_chroma(), 60.0, 0.5);
// Tone might vary, but should be within the range from 0 to 100.
EXPECT_GT(result.get_tone(), 0);
EXPECT_LT(result.get_tone(), 100);
}

TEST(KeyColorTests, UnusuallyHighChroma) {
// Requested chroma is above what is achievable. For Hue 149, chroma peak
// is 89.6 at Tone 87.9. The result key color's chroma should be close to the
// chroma peak.
TonalPalette palette = TonalPalette(149.0, 200.0);
Hct result = palette.get_key_color();

EXPECT_NEAR(result.get_hue(), 149.0, 10.0);
EXPECT_GT(result.get_chroma(), 89.0);
// Tone might vary, but should be within the range from 0 to 100.
EXPECT_GT(result.get_tone(), 0);
EXPECT_LT(result.get_tone(), 100);
}

TEST(KeyColorTests, UnusuallyLowChroma) {
// By definition, the key color should be the first tone, starting from Tone
// 50, matching the given hue and chroma. When requesting a very low chroma,
// the result should be close to Tone 50, since most tones can produce a low
// chroma.
TonalPalette palette = TonalPalette(50.0, 3.0);
Hct result = palette.get_key_color();

// Higher error tolerance for hue when the requested chroma is unusually low.
EXPECT_NEAR(result.get_hue(), 50.0, 10.0);
EXPECT_NEAR(result.get_chroma(), 3.0, 0.5);
EXPECT_NEAR(result.get_tone(), 50.0, 0.5);
}

} // namespace
} // namespace material_color_utilities
4 changes: 4 additions & 0 deletions dart/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.11.2 - 2024-04-30
### Changed
- Updated `TonalPalette` to use new key color algorithm.

## 0.11.1 - 2024-03-11
### Fixed
- Fixed Apache license
Expand Down
114 changes: 72 additions & 42 deletions dart/lib/palettes/tonal_palette.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ class TonalPalette {

TonalPalette._fromHueAndChroma(this.hue, this.chroma)
: _cache = {},
keyColor = createKeyColor(hue, chroma),
keyColor = KeyColor(hue, chroma).create(),
_isFromCache = false;

TonalPalette._fromCache(Map<int, int> cache, this.hue, this.chroma)
: _cache = cache,
keyColor = createKeyColor(hue, chroma),
keyColor = KeyColor(hue, chroma).create(),
_isFromCache = true;

/// Create colors using [hue] and [chroma].
Expand Down Expand Up @@ -113,46 +113,6 @@ class TonalPalette {
return TonalPalette._fromCache(cache, bestHue, bestChroma);
}

/// 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.
/// Key color [Hct]
static Hct createKeyColor(double hue, double chroma) {
double startTone = 50.0;
Hct smallestDeltaHct = Hct.from(hue, chroma, startTone);
double smallestDelta = (smallestDeltaHct.chroma - chroma).abs();
// 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 (chroma.round() == smallestDeltaHct.chroma.round()) {
return smallestDeltaHct;
}

final Hct hctAdd = Hct.from(hue, chroma, startTone + delta);
final double hctAddDelta = (hctAdd.chroma - chroma).abs();
if (hctAddDelta < smallestDelta) {
smallestDelta = hctAddDelta;
smallestDeltaHct = hctAdd;
}

final Hct hctSubtract = Hct.from(hue, chroma, startTone - delta);
final double hctSubtractDelta = (hctSubtract.chroma - chroma).abs();
if (hctSubtractDelta < smallestDelta) {
smallestDelta = hctSubtractDelta;
smallestDeltaHct = hctSubtract;
}
}

return smallestDeltaHct;
}

/// Returns a fixed-size list of ARGB color ints for common tone values.
///
/// Inverse of [fromList].
Expand Down Expand Up @@ -222,3 +182,73 @@ class TonalPalette {
}
}
}

/// Key color is a color that represents the hue and chroma of a tonal palette.
class KeyColor {
final double hue;
final double requestedChroma;

/// Cache that maps (hue, tone) to max chroma to avoid duplicated HCT
/// calculation.
final Map<int, double> _chromaCache = {};
final double _maxChromaValue = 200.0;

KeyColor(this.hue, this.requestedChroma);

/// 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]
Hct create() {
// Pivot around T50 because T50 has the most chroma available, on
// average. Thus it is most likely to have a direct answer.
const int pivotTone = 50;
const int toneStepSize = 1;
// Epsilon to accept values slightly higher than the requested chroma.
const double epsilon = 0.01;

// Binary search to find the tone that can provide a chroma that is closest
// to the requested chroma.
int lowerTone = 0;
int upperTone = 100;
while (lowerTone < upperTone) {
final int midTone = (lowerTone + upperTone) ~/ 2;
final bool isAscending =
_maxChroma(midTone) < _maxChroma(midTone + toneStepSize);
final bool 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 ((lowerTone - pivotTone).abs() < (upperTone - pivotTone).abs()) {
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
double _maxChroma(int tone) {
return _chromaCache.putIfAbsent(tone, () {
return Hct.from(hue, _maxChromaValue, tone.toDouble()).chroma;
});
}
}
2 changes: 1 addition & 1 deletion dart/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

name: material_color_utilities
description: Algorithms and utilities that power the Material Design 3 color system, including choosing theme colors from images and creating tones of colors; all in a new color space.
version: 0.11.1
version: 0.11.2
repository: https://github.com/material-foundation/material-color-utilities/tree/main/dart
issue_tracker: https://github.com/material-foundation/material-color-utilities/issues
screenshots:
Expand Down
Loading

0 comments on commit 935c2b4

Please sign in to comment.