blob: 3619773846b6883e2dd4253be215fbb07252a678 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// TODO(goderbauer): Fix this warning for the classes in this file.
// ignore_for_file: avoid_equals_and_hash_code_on_mutable_classes
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
// TODO(a14n): remove this import once Flutter 3.1 or later reaches stable (including flutter/flutter#104231)
// ignore: unnecessary_import
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:ui' show Color, ImageByteFormat;
import 'package:collection/collection.dart'
show HeapPriorityQueue, PriorityQueue;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
/// A description of an encoded image.
///
/// Used in [PaletteGenerator.fromByteData].
class EncodedImage {
/// Creates a description of an encoded image.
const EncodedImage(
this.byteData, {
required this.width,
required this.height,
});
/// Encoded image byte data.
final ByteData byteData;
/// Image width.
final int width;
/// Image height.
final int height;
}
/// A class to extract prominent colors from an image for use as user interface
/// colors.
///
/// To create a new [PaletteGenerator], use the asynchronous
/// [PaletteGenerator.fromImage] static function.
///
/// A number of color paletteColors with different profiles are chosen from the
/// image:
///
/// * [vibrantColor]
/// * [darkVibrantColor]
/// * [lightVibrantColor]
/// * [mutedColor]
/// * [darkMutedColor]
/// * [lightMutedColor]
///
/// You may add your own target palette color types by supplying them to the
/// `targets` parameter for [PaletteGenerator.fromImage].
///
/// In addition, the population-sorted list of discovered [colors] is available,
/// and a [paletteColors] list providing contrasting title text and body text
/// colors for each palette color.
///
/// The palette is created using a color quantizer based on the Median-cut
/// algorithm, but optimized for picking out distinct colors rather than
/// representative colors.
///
/// The color space is represented as a 3-dimensional cube with each dimension
/// being one component of an RGB image. The cube is then repeatedly divided
/// until the color space is reduced to the requested number of colors. An
/// average color is then generated from each cube.
///
/// What makes this different from a median-cut algorithm is that median-cut
/// divides cubes so that all of the cubes have roughly the same population,
/// where the quantizer that is used to create the palette divides cubes based
/// on their color volume. This means that the color space is divided into
/// distinct colors, rather than representative colors.
///
/// See also:
///
/// * [PaletteColor], to contain various pieces of metadata about a chosen
/// palette color.
/// * [PaletteTarget], to be able to create your own target color types.
/// * [PaletteFilter], a function signature for filtering the allowed colors
/// in the palette.
class PaletteGenerator with Diagnosticable {
/// Create a [PaletteGenerator] from a set of paletteColors and targets.
///
/// The usual way to create a [PaletteGenerator] is to use the asynchronous
/// [PaletteGenerator.fromImage] static function. This constructor is mainly
/// used for cases when you have your own source of color information and
/// would like to use the target selection and scoring methods here.
PaletteGenerator.fromColors(
this.paletteColors, {
this.targets = const <PaletteTarget>[],
}) : selectedSwatches = <PaletteTarget, PaletteColor>{} {
_sortSwatches();
_selectSwatches();
}
// TODO(gspencergoog): remove `dart:ui` paragraph from [fromByteData] method when https://github.com/flutter/flutter/issues/10647 is resolved
/// Create a [PaletteGenerator] asynchronously from encoded image [ByteData],
/// width, and height. These parameters are packed in [EncodedImage].
///
/// The image encoding must be RGBA with 8 bits per channel, this corresponds to
/// [ImageByteFormat.rawRgba] or [ImageByteFormat.rawStraightRgba].
///
/// In contast with [fromImage] and [fromImageProvider] this method can be used
/// in non-root isolates, because it doesn't involve interaction with the
/// `dart:ui` library, which is currently not supported, see https://github.com/flutter/flutter/issues/10647.
///
/// The [region] specifies the part of the image to inspect for color
/// candidates. By default it uses the entire image. Must not be equal to
/// [Rect.zero], and must not be larger than the image dimensions.
///
/// The [maximumColorCount] sets the maximum number of colors that will be
/// returned in the [PaletteGenerator]. The default is 16 colors.
///
/// The [filters] specify a lost of [PaletteFilter] instances that can be used
/// to include certain colors in the list of colors. The default filter is
/// an instance of [AvoidRedBlackWhitePaletteFilter], which stays away from
/// whites, blacks, and low-saturation reds.
///
/// The [targets] are a list of target color types, specified by creating
/// custom [PaletteTarget]s. By default, this is the list of targets in
/// [PaletteTarget.baseTargets].
static Future<PaletteGenerator> fromByteData(
EncodedImage encodedImage, {
Rect? region,
int maximumColorCount = _defaultCalculateNumberColors,
List<PaletteFilter> filters = const <PaletteFilter>[
avoidRedBlackWhitePaletteFilter
],
List<PaletteTarget> targets = const <PaletteTarget>[],
}) async {
assert(region == null || region != Rect.zero);
assert(
region == null ||
(region.topLeft.dx >= 0.0 && region.topLeft.dy >= 0.0),
'Region $region is outside the image ${encodedImage.width}x${encodedImage.height}');
assert(
region == null ||
(region.bottomRight.dx <= encodedImage.width &&
region.bottomRight.dy <= encodedImage.height),
'Region $region is outside the image ${encodedImage.width}x${encodedImage.height}');
assert(
encodedImage.byteData.lengthInBytes ~/ 4 ==
encodedImage.width * encodedImage.height,
"Image byte data doesn't match the image size, or has invalid encoding. "
'The encoding must be RGBA with 8 bits per channel.',
);
final _ColorCutQuantizer quantizer = _ColorCutQuantizer(
encodedImage,
maxColors: maximumColorCount,
filters: filters,
region: region,
);
final List<PaletteColor> colors = await quantizer.quantizedColors;
return PaletteGenerator.fromColors(
colors,
targets: targets,
);
}
/// Create a [PaletteGenerator] from an [dart:ui.Image] asynchronously.
///
/// The [region] specifies the part of the image to inspect for color
/// candidates. By default it uses the entire image. Must not be equal to
/// [Rect.zero], and must not be larger than the image dimensions.
///
/// The [maximumColorCount] sets the maximum number of colors that will be
/// returned in the [PaletteGenerator]. The default is 16 colors.
///
/// The [filters] specify a lost of [PaletteFilter] instances that can be used
/// to include certain colors in the list of colors. The default filter is
/// an instance of [AvoidRedBlackWhitePaletteFilter], which stays away from
/// whites, blacks, and low-saturation reds.
///
/// The [targets] are a list of target color types, specified by creating
/// custom [PaletteTarget]s. By default, this is the list of targets in
/// [PaletteTarget.baseTargets].
static Future<PaletteGenerator> fromImage(
ui.Image image, {
Rect? region,
int maximumColorCount = _defaultCalculateNumberColors,
List<PaletteFilter> filters = const <PaletteFilter>[
avoidRedBlackWhitePaletteFilter
],
List<PaletteTarget> targets = const <PaletteTarget>[],
}) async {
final ByteData? imageData = await image.toByteData();
if (imageData == null) {
throw StateError('Failed to encode the image.');
}
return PaletteGenerator.fromByteData(
EncodedImage(
imageData,
width: image.width,
height: image.height,
),
region: region,
maximumColorCount: maximumColorCount,
filters: filters,
targets: targets,
);
}
/// Create a [PaletteGenerator] from an [ImageProvider], like [FileImage], or
/// [AssetImage], asynchronously.
///
/// The [size] is the desired size of the image. The image will be resized to
/// this size before creating the [PaletteGenerator] from it.
///
/// The [region] specifies the part of the (resized) image to inspect for
/// color candidates. By default it uses the entire image. Must not be equal
/// to [Rect.zero], and must not be larger than the image dimensions.
///
/// The [maximumColorCount] sets the maximum number of colors that will be
/// returned in the [PaletteGenerator]. The default is 16 colors.
///
/// The [filters] specify a lost of [PaletteFilter] instances that can be used
/// to include certain colors in the list of colors. The default filter is
/// an instance of [AvoidRedBlackWhitePaletteFilter], which stays away from
/// whites, blacks, and low-saturation reds.
///
/// The [targets] are a list of target color types, specified by creating
/// custom [PaletteTarget]s. By default, this is the list of targets in
/// [PaletteTarget.baseTargets].
///
/// The [timeout] describes how long to wait for the image to load before
/// giving up on it. A value of Duration.zero implies waiting forever. The
/// default timeout is 15 seconds.
static Future<PaletteGenerator> fromImageProvider(
ImageProvider imageProvider, {
Size? size,
Rect? region,
int maximumColorCount = _defaultCalculateNumberColors,
List<PaletteFilter> filters = const <PaletteFilter>[
avoidRedBlackWhitePaletteFilter
],
List<PaletteTarget> targets = const <PaletteTarget>[],
Duration timeout = const Duration(seconds: 15),
}) async {
assert(region == null || size != null);
assert(region == null || region != Rect.zero);
assert(
region == null ||
(region.topLeft.dx >= 0.0 && region.topLeft.dy >= 0.0),
'Region $region is outside the image ${size!.width}x${size.height}');
assert(region == null || size!.contains(region.topLeft),
'Region $region is outside the image $size');
assert(
region == null ||
(region.bottomRight.dx <= size!.width &&
region.bottomRight.dy <= size.height),
'Region $region is outside the image $size');
final ImageStream stream = imageProvider.resolve(
ImageConfiguration(size: size, devicePixelRatio: 1.0),
);
final Completer<ui.Image> imageCompleter = Completer<ui.Image>();
Timer? loadFailureTimeout;
late ImageStreamListener listener;
listener = ImageStreamListener((ImageInfo info, bool synchronousCall) {
loadFailureTimeout?.cancel();
stream.removeListener(listener);
imageCompleter.complete(info.image);
});
if (timeout != Duration.zero) {
loadFailureTimeout = Timer(timeout, () {
stream.removeListener(listener);
imageCompleter.completeError(
TimeoutException(
'Timeout occurred trying to load from $imageProvider'),
);
});
}
stream.addListener(listener);
final ui.Image image = await imageCompleter.future;
ui.Rect? newRegion = region;
if (size != null && region != null) {
final double scale = image.width / size.width;
newRegion = Rect.fromLTRB(
region.left * scale,
region.top * scale,
region.right * scale,
region.bottom * scale,
);
}
return PaletteGenerator.fromImage(
image,
region: newRegion,
maximumColorCount: maximumColorCount,
filters: filters,
targets: targets,
);
}
static const int _defaultCalculateNumberColors = 16;
/// Provides a map of the selected paletteColors for each target in [targets].
final Map<PaletteTarget, PaletteColor> selectedSwatches;
/// The list of [PaletteColor]s that make up the palette, sorted from most
/// dominant color to least dominant color.
final List<PaletteColor> paletteColors;
/// The list of targets that the palette uses for custom color selection.
///
/// By default, this contains the entire list of predefined targets in
/// [PaletteTarget.baseTargets].
final List<PaletteTarget> targets;
/// Returns a list of colors in the [paletteColors], sorted from most
/// dominant to least dominant color.
Iterable<Color> get colors sync* {
for (final PaletteColor paletteColor in paletteColors) {
yield paletteColor.color;
}
}
/// Returns a vibrant color from the palette. Might be null if an appropriate
/// target color could not be found.
PaletteColor? get vibrantColor => selectedSwatches[PaletteTarget.vibrant];
/// Returns a light and vibrant color from the palette. Might be null if an
/// appropriate target color could not be found.
PaletteColor? get lightVibrantColor =>
selectedSwatches[PaletteTarget.lightVibrant];
/// Returns a dark and vibrant color from the palette. Might be null if an
/// appropriate target color could not be found.
PaletteColor? get darkVibrantColor =>
selectedSwatches[PaletteTarget.darkVibrant];
/// Returns a muted color from the palette. Might be null if an appropriate
/// target color could not be found.
PaletteColor? get mutedColor => selectedSwatches[PaletteTarget.muted];
/// Returns a muted and light color from the palette. Might be null if an
/// appropriate target color could not be found.
PaletteColor? get lightMutedColor =>
selectedSwatches[PaletteTarget.lightMuted];
/// Returns a muted and dark color from the palette. Might be null if an
/// appropriate target color could not be found.
PaletteColor? get darkMutedColor => selectedSwatches[PaletteTarget.darkMuted];
/// The dominant color (the color with the largest population).
PaletteColor? get dominantColor => _dominantColor;
PaletteColor? _dominantColor;
void _sortSwatches() {
if (paletteColors.isEmpty) {
_dominantColor = null;
return;
}
// Sort from most common to least common.
paletteColors.sort((PaletteColor a, PaletteColor b) {
return b.population.compareTo(a.population);
});
_dominantColor = paletteColors[0];
}
void _selectSwatches() {
final Set<PaletteTarget> allTargets =
Set<PaletteTarget>.from(targets + PaletteTarget.baseTargets);
final Set<Color> usedColors = <Color>{};
for (final PaletteTarget target in allTargets) {
target._normalizeWeights();
final PaletteColor? targetScore =
_generateScoredTarget(target, usedColors);
if (targetScore != null) {
selectedSwatches[target] = targetScore;
}
}
}
PaletteColor? _generateScoredTarget(
PaletteTarget target, Set<Color> usedColors) {
final PaletteColor? maxScoreSwatch =
_getMaxScoredSwatchForTarget(target, usedColors);
if (maxScoreSwatch != null && target.isExclusive) {
// If we have a color, and the target is exclusive, add the color to the
// used list.
usedColors.add(maxScoreSwatch.color);
}
return maxScoreSwatch;
}
PaletteColor? _getMaxScoredSwatchForTarget(
PaletteTarget target, Set<Color> usedColors) {
double maxScore = 0.0;
PaletteColor? maxScoreSwatch;
for (final PaletteColor paletteColor in paletteColors) {
if (_shouldBeScoredForTarget(paletteColor, target, usedColors)) {
final double score = _generateScore(paletteColor, target);
if (maxScoreSwatch == null || score > maxScore) {
maxScoreSwatch = paletteColor;
maxScore = score;
}
}
}
return maxScoreSwatch;
}
bool _shouldBeScoredForTarget(
PaletteColor paletteColor, PaletteTarget target, Set<Color> usedColors) {
// Check whether the HSL lightness is within the correct range, and that
// this color hasn't been used yet.
final HSLColor hslColor = HSLColor.fromColor(paletteColor.color);
return hslColor.saturation >= target.minimumSaturation &&
hslColor.saturation <= target.maximumSaturation &&
hslColor.lightness >= target.minimumLightness &&
hslColor.lightness <= target.maximumLightness &&
!usedColors.contains(paletteColor.color);
}
double _generateScore(PaletteColor paletteColor, PaletteTarget target) {
final HSLColor hslColor = HSLColor.fromColor(paletteColor.color);
double saturationScore = 0.0;
double valueScore = 0.0;
double populationScore = 0.0;
if (target.saturationWeight > 0.0) {
saturationScore = target.saturationWeight *
(1.0 - (hslColor.saturation - target.targetSaturation).abs());
}
if (target.lightnessWeight > 0.0) {
valueScore = target.lightnessWeight *
(1.0 - (hslColor.lightness - target.targetLightness).abs());
}
if (_dominantColor != null && target.populationWeight > 0.0) {
populationScore = target.populationWeight *
(paletteColor.population / _dominantColor!.population);
}
return saturationScore + valueScore + populationScore;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IterableProperty<PaletteColor>(
'paletteColors', paletteColors,
defaultValue: <PaletteColor>[]));
properties.add(IterableProperty<PaletteTarget>('targets', targets,
defaultValue: PaletteTarget.baseTargets));
}
}
/// A class which allows custom selection of colors when a [PaletteGenerator] is
/// generated.
///
/// To add a target, supply it to the `targets` list in
/// [PaletteGenerator.fromImage] or [PaletteGenerator..fromColors].
///
/// See also:
///
/// * [PaletteGenerator], a class for selecting color palettes from images.
class PaletteTarget with Diagnosticable {
/// Creates a [PaletteTarget] for custom palette selection.
PaletteTarget({
this.minimumSaturation = 0.0,
this.targetSaturation = 0.5,
this.maximumSaturation = 1.0,
this.minimumLightness = 0.0,
this.targetLightness = 0.5,
this.maximumLightness = 1.0,
this.isExclusive = true,
});
/// The minimum saturation value for this target.
final double minimumSaturation;
/// The target saturation value for this target.
final double targetSaturation;
/// The maximum saturation value for this target.
final double maximumSaturation;
/// The minimum lightness value for this target.
final double minimumLightness;
/// The target lightness value for this target.
final double targetLightness;
/// The maximum lightness value for this target.
final double maximumLightness;
/// Returns whether any color selected for this target is exclusive for this
/// target only.
///
/// If false, then the color can also be selected for other targets. Defaults
/// to true.
final bool isExclusive;
/// The weight of importance that a color's saturation value has on selection.
double saturationWeight = _weightSaturation;
/// The weight of importance that a color's lightness value has on selection.
double lightnessWeight = _weightLightness;
/// The weight of importance that a color's population value has on selection.
double populationWeight = _weightPopulation;
static const double _targetDarkLightness = 0.26;
static const double _maxDarkLightness = 0.45;
static const double _minLightLightness = 0.55;
static const double _targetLightLightness = 0.74;
static const double _minNormalLightness = 0.3;
static const double _maxNormalLightness = 0.7;
static const double _targetMutedSaturation = 0.3;
static const double _maxMutedSaturation = 0.4;
static const double _targetVibrantSaturation = 1.0;
static const double _minVibrantSaturation = 0.35;
static const double _weightSaturation = 0.24;
static const double _weightLightness = 0.52;
static const double _weightPopulation = 0.24;
/// A target which has the characteristics of a vibrant color which is light
/// in luminance.
///
/// One of the base set of `targets` for [PaletteGenerator.fromImage], in [baseTargets].
static final PaletteTarget lightVibrant = PaletteTarget(
targetLightness: _targetLightLightness,
minimumLightness: _minLightLightness,
minimumSaturation: _minVibrantSaturation,
targetSaturation: _targetVibrantSaturation,
);
/// A target which has the characteristics of a vibrant color which is neither
/// light or dark.
///
/// One of the base set of `targets` for [PaletteGenerator.fromImage], in [baseTargets].
static final PaletteTarget vibrant = PaletteTarget(
minimumLightness: _minNormalLightness,
maximumLightness: _maxNormalLightness,
minimumSaturation: _minVibrantSaturation,
targetSaturation: _targetVibrantSaturation,
);
/// A target which has the characteristics of a vibrant color which is dark in
/// luminance.
///
/// One of the base set of `targets` for [PaletteGenerator.fromImage], in [baseTargets].
static final PaletteTarget darkVibrant = PaletteTarget(
targetLightness: _targetDarkLightness,
maximumLightness: _maxDarkLightness,
minimumSaturation: _minVibrantSaturation,
targetSaturation: _targetVibrantSaturation,
);
/// A target which has the characteristics of a muted color which is light in
/// luminance.
///
/// One of the base set of `targets` for [PaletteGenerator.fromImage], in [baseTargets].
static final PaletteTarget lightMuted = PaletteTarget(
targetLightness: _targetLightLightness,
minimumLightness: _minLightLightness,
targetSaturation: _targetMutedSaturation,
maximumSaturation: _maxMutedSaturation,
);
/// A target which has the characteristics of a muted color which is neither
/// light or dark.
///
/// One of the base set of `targets` for [PaletteGenerator.fromImage], in [baseTargets].
static final PaletteTarget muted = PaletteTarget(
minimumLightness: _minNormalLightness,
maximumLightness: _maxNormalLightness,
targetSaturation: _targetMutedSaturation,
maximumSaturation: _maxMutedSaturation,
);
/// A target which has the characteristics of a muted color which is dark in
/// luminance.
///
/// One of the base set of `targets` for [PaletteGenerator.fromImage], in [baseTargets].
static final PaletteTarget darkMuted = PaletteTarget(
targetLightness: _targetDarkLightness,
maximumLightness: _maxDarkLightness,
targetSaturation: _targetMutedSaturation,
maximumSaturation: _maxMutedSaturation,
);
/// A list of all the available predefined targets.
///
/// The base set of `targets` for [PaletteGenerator.fromImage].
static final List<PaletteTarget> baseTargets = <PaletteTarget>[
lightVibrant,
vibrant,
darkVibrant,
lightMuted,
muted,
darkMuted,
];
void _normalizeWeights() {
final double sum = saturationWeight + lightnessWeight + populationWeight;
if (sum != 0.0) {
saturationWeight /= sum;
lightnessWeight /= sum;
populationWeight /= sum;
}
}
@override
bool operator ==(Object other) {
return other is PaletteTarget &&
minimumSaturation == other.minimumSaturation &&
targetSaturation == other.targetSaturation &&
maximumSaturation == other.maximumSaturation &&
minimumLightness == other.minimumLightness &&
targetLightness == other.targetLightness &&
maximumLightness == other.maximumLightness &&
saturationWeight == other.saturationWeight &&
lightnessWeight == other.lightnessWeight &&
populationWeight == other.populationWeight;
}
@override
int get hashCode {
return Object.hash(
minimumSaturation,
targetSaturation,
maximumSaturation,
minimumLightness,
targetLightness,
maximumLightness,
saturationWeight,
lightnessWeight,
populationWeight,
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
final PaletteTarget defaultTarget = PaletteTarget();
properties.add(DoubleProperty('minimumSaturation', minimumSaturation,
defaultValue: defaultTarget.minimumSaturation));
properties.add(DoubleProperty('targetSaturation', targetSaturation,
defaultValue: defaultTarget.targetSaturation));
properties.add(DoubleProperty('maximumSaturation', maximumSaturation,
defaultValue: defaultTarget.maximumSaturation));
properties.add(DoubleProperty('minimumLightness', minimumLightness,
defaultValue: defaultTarget.minimumLightness));
properties.add(DoubleProperty('targetLightness', targetLightness,
defaultValue: defaultTarget.targetLightness));
properties.add(DoubleProperty('maximumLightness', maximumLightness,
defaultValue: defaultTarget.maximumLightness));
properties.add(DoubleProperty('saturationWeight', saturationWeight,
defaultValue: defaultTarget.saturationWeight));
properties.add(DoubleProperty('lightnessWeight', lightnessWeight,
defaultValue: defaultTarget.lightnessWeight));
properties.add(DoubleProperty('populationWeight', populationWeight,
defaultValue: defaultTarget.populationWeight));
}
}
typedef _ContrastCalculator = double Function(Color a, Color b, int alpha);
/// A color palette color generated by the [PaletteGenerator].
///
/// This palette color represents a dominant [color] in an image, and has a
/// [population] of how many pixels in the source image it represents. It picks
/// a [titleTextColor] and a [bodyTextColor] that contrast sufficiently with the
/// source [color] for comfortable reading.
///
/// See also:
///
/// * [PaletteGenerator], a class for selecting color palettes from images.
class PaletteColor with Diagnosticable {
/// Generate a [PaletteColor].
PaletteColor(this.color, this.population);
static const double _minContrastTitleText = 3.0;
static const double _minContrastBodyText = 4.5;
/// The color that this palette color represents.
final Color color;
/// The number of pixels in the source image that this palette color
/// represents.
final int population;
/// The color of title text for use with this palette color.
Color get titleTextColor {
if (_titleTextColor == null) {
_ensureTextColorsGenerated();
}
return _titleTextColor!;
}
Color? _titleTextColor;
/// The color of body text for use with this palette color.
Color get bodyTextColor {
if (_bodyTextColor == null) {
_ensureTextColorsGenerated();
}
return _bodyTextColor!;
}
Color? _bodyTextColor;
void _ensureTextColorsGenerated() {
if (_titleTextColor == null || _bodyTextColor == null) {
const Color white = Color(0xffffffff);
const Color black = Color(0xff000000);
// First check white, as most colors will be dark
final int? lightBodyAlpha =
_calculateMinimumAlpha(white, color, _minContrastBodyText);
final int? lightTitleAlpha =
_calculateMinimumAlpha(white, color, _minContrastTitleText);
if (lightBodyAlpha != null && lightTitleAlpha != null) {
// If we found valid light values, use them and return
_bodyTextColor = white.withAlpha(lightBodyAlpha);
_titleTextColor = white.withAlpha(lightTitleAlpha);
return;
}
final int? darkBodyAlpha =
_calculateMinimumAlpha(black, color, _minContrastBodyText);
final int? darkTitleAlpha =
_calculateMinimumAlpha(black, color, _minContrastTitleText);
if (darkBodyAlpha != null && darkTitleAlpha != null) {
// If we found valid dark values, use them and return
_bodyTextColor = black.withAlpha(darkBodyAlpha);
_titleTextColor = black.withAlpha(darkTitleAlpha);
return;
}
// If we reach here then we can not find title and body values which use
// the same lightness, we need to use mismatched values
_bodyTextColor = lightBodyAlpha != null
? white.withAlpha(lightBodyAlpha)
: black.withAlpha(darkBodyAlpha ?? 255);
_titleTextColor = lightTitleAlpha != null
? white.withAlpha(lightTitleAlpha)
: black.withAlpha(darkTitleAlpha ?? 255);
}
}
/// Returns the contrast ratio between [foreground] and [background].
/// [background] must be opaque.
///
/// Formula defined [here](http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef).
static double _calculateContrast(Color foreground, Color background) {
assert(background.alpha == 0xff,
'background can not be translucent: $background.');
if (foreground.alpha < 0xff) {
// If the foreground is translucent, composite the foreground over the
// background
foreground = Color.alphaBlend(foreground, background);
}
final double lightness1 = foreground.computeLuminance() + 0.05;
final double lightness2 = background.computeLuminance() + 0.05;
return math.max(lightness1, lightness2) / math.min(lightness1, lightness2);
}
// Calculates the minimum alpha value which can be applied to foreground that
// would have a contrast value of at least [minContrastRatio] when compared to
// background.
//
// The background must be opaque (alpha of 255).
//
// Returns the alpha value in the range 0-255, or null if no value could be
// calculated.
static int? _calculateMinimumAlpha(
Color foreground, Color background, double minContrastRatio) {
assert(background.alpha == 0xff,
'The background cannot be translucent: $background.');
double contrastCalculator(Color fg, Color bg, int alpha) {
final Color testForeground = fg.withAlpha(alpha);
return _calculateContrast(testForeground, bg);
}
// First lets check that a fully opaque foreground has sufficient contrast
final double testRatio = contrastCalculator(foreground, background, 0xff);
if (testRatio < minContrastRatio) {
// Fully opaque foreground does not have sufficient contrast, return error
return null;
}
foreground = foreground.withAlpha(0xff);
return _binaryAlphaSearch(
foreground, background, minContrastRatio, contrastCalculator);
}
// Calculates the alpha value using binary search based on a given contrast
// evaluation function and target contrast that needs to be satisfied.
//
// The background must be opaque (alpha of 255).
//
// Returns the alpha value in the range [0, 255].
static int _binaryAlphaSearch(
Color foreground,
Color background,
double minContrastRatio,
_ContrastCalculator calculator,
) {
assert(background.alpha == 0xff,
'The background cannot be translucent: $background.');
const int minAlphaSearchMaxIterations = 10;
const int minAlphaSearchPrecision = 1;
// Binary search to find a value with the minimum value which provides
// sufficient contrast
int numIterations = 0;
int minAlpha = 0;
int maxAlpha = 0xff;
while (numIterations <= minAlphaSearchMaxIterations &&
(maxAlpha - minAlpha) > minAlphaSearchPrecision) {
final int testAlpha = (minAlpha + maxAlpha) ~/ 2;
final double testRatio = calculator(foreground, background, testAlpha);
if (testRatio < minContrastRatio) {
minAlpha = testAlpha;
} else {
maxAlpha = testAlpha;
}
numIterations++;
}
// Conservatively return the max of the range of possible alphas, which is
// known to pass.
return maxAlpha;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Color>('color', color));
properties
.add(DiagnosticsProperty<Color>('titleTextColor', titleTextColor));
properties.add(DiagnosticsProperty<Color>('bodyTextColor', bodyTextColor));
properties.add(IntProperty('population', population, defaultValue: 0));
}
@override
int get hashCode => Object.hash(color, population);
@override
bool operator ==(Object other) {
return other is PaletteColor &&
color == other.color &&
population == other.population;
}
}
/// Hook to allow clients to be able filter colors from selected in a
/// [PaletteGenerator]. Returns true if the [color] is allowed.
///
/// See also:
///
/// * [PaletteGenerator.fromImage], which takes a list of these for its
/// `filters` parameter.
/// * [avoidRedBlackWhitePaletteFilter], the default filter for
/// [PaletteGenerator].
typedef PaletteFilter = bool Function(HSLColor color);
/// A basic [PaletteFilter], which rejects colors near black, white and low
/// saturation red.
///
/// Use this as an element in the `filters` list given to [PaletteGenerator.fromImage].
///
/// See also:
/// * [PaletteGenerator], a class for selecting color palettes from images.
bool avoidRedBlackWhitePaletteFilter(HSLColor color) {
bool isBlack(HSLColor hslColor) {
const double blackMaxLightness = 0.05;
return hslColor.lightness <= blackMaxLightness;
}
bool isWhite(HSLColor hslColor) {
const double whiteMinLightness = 0.95;
return hslColor.lightness >= whiteMinLightness;
}
// Returns true if the color is close to the red side of the I line.
bool isNearRedILine(HSLColor hslColor) {
const double redLineMinHue = 10.0;
const double redLineMaxHue = 37.0;
const double redLineMaxSaturation = 0.82;
return hslColor.hue >= redLineMinHue &&
hslColor.hue <= redLineMaxHue &&
hslColor.saturation <= redLineMaxSaturation;
}
return !isWhite(color) && !isBlack(color) && !isNearRedILine(color);
}
enum _ColorComponent {
red,
green,
blue,
}
/// A box that represents a volume in the RGB color space.
class _ColorVolumeBox {
_ColorVolumeBox(
this._lowerIndex, this._upperIndex, this.histogram, this.colors) {
_fitMinimumBox();
}
final _ColorHistogram histogram;
final List<Color> colors;
// The lower and upper index are inclusive.
final int _lowerIndex;
int _upperIndex;
// The population of colors within this box.
late int _population;
// Bounds in each of the dimensions.
late int _minRed;
late int _maxRed;
late int _minGreen;
late int _maxGreen;
late int _minBlue;
late int _maxBlue;
int getVolume() {
return (_maxRed - _minRed + 1) *
(_maxGreen - _minGreen + 1) *
(_maxBlue - _minBlue + 1);
}
bool canSplit() {
return getColorCount() > 1;
}
int getColorCount() {
return 1 + _upperIndex - _lowerIndex;
}
/// Recomputes the boundaries of this box to tightly fit the colors within the
/// box.
void _fitMinimumBox() {
// Reset the min and max to opposite values
int minRed = 256;
int minGreen = 256;
int minBlue = 256;
int maxRed = -1;
int maxGreen = -1;
int maxBlue = -1;
int count = 0;
for (int i = _lowerIndex; i <= _upperIndex; i++) {
final Color color = colors[i];
count += histogram[color]!.value;
if (color.red > maxRed) {
maxRed = color.red;
}
if (color.red < minRed) {
minRed = color.red;
}
if (color.green > maxGreen) {
maxGreen = color.green;
}
if (color.green < minGreen) {
minGreen = color.green;
}
if (color.blue > maxBlue) {
maxBlue = color.blue;
}
if (color.blue < minBlue) {
minBlue = color.blue;
}
}
_minRed = minRed;
_maxRed = maxRed;
_minGreen = minGreen;
_maxGreen = maxGreen;
_minBlue = minBlue;
_maxBlue = maxBlue;
_population = count;
}
/// Split this color box at the mid-point along it's longest dimension.
///
/// Returns the new ColorBox.
_ColorVolumeBox splitBox() {
assert(canSplit(), "Can't split a box with only 1 color");
// find median along the longest dimension
final int splitPoint = _findSplitPoint();
final _ColorVolumeBox newBox =
_ColorVolumeBox(splitPoint + 1, _upperIndex, histogram, colors);
// Now change this box's upperIndex and recompute the color boundaries
_upperIndex = splitPoint;
_fitMinimumBox();
return newBox;
}
/// Returns the largest dimension of this color box.
_ColorComponent _getLongestColorDimension() {
final int redLength = _maxRed - _minRed;
final int greenLength = _maxGreen - _minGreen;
final int blueLength = _maxBlue - _minBlue;
if (redLength >= greenLength && redLength >= blueLength) {
return _ColorComponent.red;
} else if (greenLength >= redLength && greenLength >= blueLength) {
return _ColorComponent.green;
} else {
return _ColorComponent.blue;
}
}
// Finds where to split this box between _lowerIndex and _upperIndex.
//
// The split point is calculated by finding the longest color dimension, and
// then sorting the sub-array based on that dimension value in each color.
// The colors are then iterated over until a color is found with the
// midpoint closest to the whole box's dimension midpoint.
//
// Returns the index of the split point in the colors array.
int _findSplitPoint() {
final _ColorComponent longestDimension = _getLongestColorDimension();
int compareColors(Color a, Color b) {
int makeValue(int first, int second, int third) {
return first << 16 | second << 8 | third;
}
switch (longestDimension) {
case _ColorComponent.red:
final int aValue = makeValue(a.red, a.green, a.blue);
final int bValue = makeValue(b.red, b.green, b.blue);
return aValue.compareTo(bValue);
case _ColorComponent.green:
final int aValue = makeValue(a.green, a.red, a.blue);
final int bValue = makeValue(b.green, b.red, b.blue);
return aValue.compareTo(bValue);
case _ColorComponent.blue:
final int aValue = makeValue(a.blue, a.green, a.red);
final int bValue = makeValue(b.blue, b.green, b.red);
return aValue.compareTo(bValue);
}
}
// We need to sort the colors in this box based on the longest color
// dimension.
final List<Color> colorSubset =
colors.sublist(_lowerIndex, _upperIndex + 1);
colorSubset.sort(compareColors);
colors.replaceRange(_lowerIndex, _upperIndex + 1, colorSubset);
final int median = (_population / 2).round();
for (int i = 0, count = 0; i <= colorSubset.length; i++) {
count += histogram[colorSubset[i]]!.value;
if (count >= median) {
// We never want to split on the upperIndex, as this will result in the
// same box.
return math.min(_upperIndex - 1, i + _lowerIndex);
}
}
return _lowerIndex;
}
PaletteColor getAverageColor() {
int redSum = 0;
int greenSum = 0;
int blueSum = 0;
int totalPopulation = 0;
for (int i = _lowerIndex; i <= _upperIndex; i++) {
final Color color = colors[i];
final int colorPopulation = histogram[color]!.value;
totalPopulation += colorPopulation;
redSum += colorPopulation * color.red;
greenSum += colorPopulation * color.green;
blueSum += colorPopulation * color.blue;
}
final int redMean = (redSum / totalPopulation).round();
final int greenMean = (greenSum / totalPopulation).round();
final int blueMean = (blueSum / totalPopulation).round();
return PaletteColor(
Color.fromARGB(0xff, redMean, greenMean, blueMean),
totalPopulation,
);
}
}
/// Holds mutable count for a color.
// Using a mutable count rather than replacing value in the histogram
// in the _ColorCutQuantizer speeds up building the histogram significantly.
class _ColorCount {
int value = 0;
}
class _ColorHistogram {
final Map<int, Map<int, Map<int, _ColorCount>>> _hist =
<int, Map<int, Map<int, _ColorCount>>>{};
final DoubleLinkedQueue<Color> _keys = DoubleLinkedQueue<Color>();
_ColorCount? operator [](Color color) {
final Map<int, Map<int, _ColorCount>>? redMap = _hist[color.red];
if (redMap == null) {
return null;
}
final Map<int, _ColorCount>? blueMap = redMap[color.blue];
if (blueMap == null) {
return null;
}
return blueMap[color.green];
}
void operator []=(Color key, _ColorCount value) {
final int red = key.red;
final int blue = key.blue;
final int green = key.green;
bool newColor = false;
Map<int, Map<int, _ColorCount>>? redMap = _hist[red];
if (redMap == null) {
_hist[red] = redMap = <int, Map<int, _ColorCount>>{};
newColor = true;
}
Map<int, _ColorCount>? blueMap = redMap[blue];
if (blueMap == null) {
redMap[blue] = blueMap = <int, _ColorCount>{};
newColor = true;
}
if (blueMap[green] == null) {
newColor = true;
}
blueMap[green] = value;
if (newColor) {
_keys.add(key);
}
}
void removeWhere(bool Function(Color key) predicate) {
for (final Color key in _keys) {
if (predicate(key)) {
_hist[key.red]?[key.blue]?.remove(key.green);
}
}
_keys.removeWhere((Color color) => predicate(color));
}
Iterable<Color> get keys {
return _keys;
}
int get length {
return _keys.length;
}
}
class _ColorCutQuantizer {
_ColorCutQuantizer(
this.encodedImage, {
this.maxColors = PaletteGenerator._defaultCalculateNumberColors,
this.region,
this.filters = const <PaletteFilter>[avoidRedBlackWhitePaletteFilter],
}) : assert(region == null || region != Rect.zero);
final EncodedImage encodedImage;
final int maxColors;
final Rect? region;
final List<PaletteFilter> filters;
Completer<List<PaletteColor>>? _paletteColorsCompleter;
FutureOr<List<PaletteColor>> get quantizedColors async {
if (_paletteColorsCompleter == null) {
_paletteColorsCompleter = Completer<List<PaletteColor>>();
_paletteColorsCompleter!.complete(_quantizeColors());
}
return _paletteColorsCompleter!.future;
}
Iterable<Color> _getImagePixels(ByteData pixels, int width, int height,
{Rect? region}) sync* {
final int rowStride = width * 4;
int rowStart;
int rowEnd;
int colStart;
int colEnd;
if (region != null) {
rowStart = region.top.floor();
rowEnd = region.bottom.floor();
colStart = region.left.floor();
colEnd = region.right.floor();
assert(rowStart >= 0);
assert(rowEnd <= height);
assert(colStart >= 0);
assert(colEnd <= width);
} else {
rowStart = 0;
rowEnd = height;
colStart = 0;
colEnd = width;
}
int byteCount = 0;
for (int row = rowStart; row < rowEnd; ++row) {
for (int col = colStart; col < colEnd; ++col) {
final int position = row * rowStride + col * 4;
// Convert from RGBA to ARGB.
final int pixel = pixels.getUint32(position);
final Color result = Color((pixel << 24) | (pixel >> 8));
byteCount += 4;
yield result;
}
}
assert(byteCount == ((rowEnd - rowStart) * (colEnd - colStart) * 4));
}
bool _shouldIgnoreColor(Color color) {
final HSLColor hslColor = HSLColor.fromColor(color);
if (filters.isNotEmpty) {
for (final PaletteFilter filter in filters) {
if (!filter(hslColor)) {
return true;
}
}
}
return false;
}
List<PaletteColor> _quantizeColors() {
const int quantizeWordWidth = 5;
const int quantizeChannelWidth = 8;
const int quantizeShift = quantizeChannelWidth - quantizeWordWidth;
const int quantizeWordMask =
((1 << quantizeWordWidth) - 1) << quantizeShift;
Color quantizeColor(Color color) {
return Color.fromARGB(
color.alpha,
color.red & quantizeWordMask,
color.green & quantizeWordMask,
color.blue & quantizeWordMask,
);
}
final List<PaletteColor> paletteColors = <PaletteColor>[];
final Iterable<Color> pixels = _getImagePixels(
encodedImage.byteData,
encodedImage.width,
encodedImage.height,
region: region,
);
final _ColorHistogram hist = _ColorHistogram();
Color? currentColor;
_ColorCount? currentColorCount;
for (final Color pixel in pixels) {
// Update the histogram, but only for non-zero alpha values, and for the
// ones we do add, make their alphas opaque so that we can use a Color as
// the histogram key.
final Color quantizedColor = quantizeColor(pixel);
final Color colorKey = quantizedColor.withAlpha(0xff);
// Skip pixels that are entirely transparent.
if (quantizedColor.alpha == 0x0) {
continue;
}
if (currentColor != colorKey) {
currentColor = colorKey;
currentColorCount = hist[colorKey];
if (currentColorCount == null) {
hist[colorKey] = currentColorCount = _ColorCount();
}
}
currentColorCount!.value = currentColorCount.value + 1;
}
// Now let's remove any colors that the filters want to ignore.
hist.removeWhere((Color color) {
return _shouldIgnoreColor(color);
});
if (hist.length <= maxColors) {
// The image has fewer colors than the maximum requested, so just return
// the colors.
paletteColors.clear();
for (final Color color in hist.keys) {
paletteColors.add(PaletteColor(color, hist[color]!.value));
}
} else {
// We need use quantization to reduce the number of colors
paletteColors.clear();
paletteColors.addAll(_quantizePixels(maxColors, hist));
}
return paletteColors;
}
List<PaletteColor> _quantizePixels(
int maxColors,
_ColorHistogram histogram,
) {
int volumeComparator(_ColorVolumeBox a, _ColorVolumeBox b) {
return b.getVolume().compareTo(a.getVolume());
}
// Create the priority queue which is sorted by volume descending. This
// means we always split the largest box in the queue
final PriorityQueue<_ColorVolumeBox> priorityQueue =
HeapPriorityQueue<_ColorVolumeBox>(volumeComparator);
// To start, offer a box which contains all of the colors
priorityQueue.add(_ColorVolumeBox(
0, histogram.length - 1, histogram, histogram.keys.toList()));
// Now go through the boxes, splitting them until we have reached maxColors
// or there are no more boxes to split
_splitBoxes(priorityQueue, maxColors);
// Finally, return the average colors of the color boxes.
return _generateAverageColors(priorityQueue);
}
// Iterate through the [PriorityQueue], popping [_ColorVolumeBox] objects
// from the queue and splitting them. Once split, the new box and the
// remaining box are offered back to the queue.
//
// The `maxSize` is the maximum number of boxes to split.
void _splitBoxes(PriorityQueue<_ColorVolumeBox> queue, final int maxSize) {
while (queue.length < maxSize) {
final _ColorVolumeBox colorVolumeBox = queue.removeFirst();
if (colorVolumeBox.canSplit()) {
// First split the box, and offer the result
queue.add(colorVolumeBox.splitBox());
// Then offer the box back
queue.add(colorVolumeBox);
} else {
// If we get here then there are no more boxes to split, so return
return;
}
}
}
// Generates the average colors from each of the boxes in the queue.
List<PaletteColor> _generateAverageColors(
PriorityQueue<_ColorVolumeBox> colorVolumeBoxes) {
final List<PaletteColor> colors = <PaletteColor>[];
for (final _ColorVolumeBox colorVolumeBox in colorVolumeBoxes.toList()) {
final PaletteColor paletteColor = colorVolumeBox.getAverageColor();
if (!_shouldIgnoreColor(paletteColor.color)) {
colors.add(paletteColor);
}
}
return colors;
}
}