Skip to content

Commit

Permalink
ambiance: add functions for color extraction
Browse files Browse the repository at this point in the history
  • Loading branch information
arnemolland committed Jun 8, 2023
1 parent 677b647 commit 7c24918
Show file tree
Hide file tree
Showing 4 changed files with 316 additions and 0 deletions.
1 change: 1 addition & 0 deletions lib/src/ambiance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export 'ambiance/algorithm/luminance.dart';
export 'ambiance/algorithm/palette.dart';
export 'ambiance/algorithm/brighten.dart';
export 'ambiance/algorithm/saturate.dart';
export 'ambiance/algorithm/extract.dart';

// Conversion
export 'ambiance/conversion/rgb.dart';
Expand Down
197 changes: 197 additions & 0 deletions lib/src/ambiance/algorithm/extract.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui';

import 'package:flutter_cache_manager/flutter_cache_manager.dart';

/// Returns the mean average color from an image (average of all colors).
/// If [squared] is true, the mean is calculated using the squared value of each
/// color channel. This is useful for calculating the mean of a color palette.
/// If [squared] is false, the mean is calculated using the raw value of each
/// color channel. This is useful for calculating the mean of an image.
/// All colors with an alpha value less than 5 are ignored, as well as colors
/// that are very close to white or black: `(r > 245 && g > 245 && b > 245) ||
/// (r < 15 && g < 15 && b < 15)`
Future<Color> mean(Image img, [bool squared = false]) async {
final width = img.width;
final height = img.height;
final size = width * height;
var redTotal = 0.0, greenTotal = 0.0, blueTotal = 0.0;
var ignored = 0;

final ByteData? byteData = await img.toByteData();

if (byteData == null) {
throw Exception('Failed to convert image to byte data');
}

final pixelData = byteData.buffer.asUint32List();

for (var i = 0; i < size; i++) {
final pixel = pixelData[i];
final r = pixel & 0xFF;
final g = (pixel >> 8) & 0xFF;
final b = (pixel >> 16) & 0xFF;
final a = (pixel >> 24) & 0xFF;

if ((r > 245 && g > 245 && b > 245) ||
(r < 15 && g < 15 && b < 15) ||
a < 5) {
ignored++;
continue;
}

if (squared) {
redTotal += r * r.toDouble();
greenTotal += g * g.toDouble();
blueTotal += b * b.toDouble();
} else {
redTotal += r.toDouble();
greenTotal += g.toDouble();
blueTotal += b.toDouble();
}
}

final pixels = size - ignored;
final red =
squared ? sqrt(redTotal / pixels).round() : (redTotal / pixels).round();
final green = squared
? sqrt(greenTotal / pixels).round()
: (greenTotal / pixels).round();
final blue =
squared ? sqrt(blueTotal / pixels).round() : (blueTotal / pixels).round();

return Color.fromRGBO(red, green, blue, 1.0);
}

/// Returns the mode average color from an image (most commonly occurring color).
/// If [squared] is true, the mode is calculated using the squared value of each
/// color channel. This is useful for calculating the mode of a color palette.
/// If [squared] is false, the mode is calculated using the raw value of each
/// color channel. This is useful for calculating the mode of an image.
/// All colors with an alpha value less than 5 are ignored, as well as colors
/// that are very close to white or black: `(r > 245 && g > 245 && b > 245) ||
/// (r < 15 && g < 15 && b < 15)`
/// If there are multiple colors with the same frequency, the first color
/// encountered is returned.
///
/// This function can be very slow, especially for large images. Consider using
/// [mean] if you need a faster alternative.
Future<Color> modal(Image img, [bool squared = false]) async {
final width = img.width;
final height = img.height;
final size = width * height;
final colorCounts = <Color, int>{};

final byteData = await img.toByteData();

if (byteData == null) {
throw Exception('Failed to convert image to byte data');
}

final pixels = byteData.buffer.asUint32List();

for (var i = 0; i < size; i++) {
final pixel = pixels[i];
final r = pixel & 0xFF;
final g = (pixel >> 8) & 0xFF;
final b = (pixel >> 16) & 0xFF;
final a = (pixel >> 24) & 0xFF;

if ((r > 245 && g > 245 && b > 245) ||
(r < 15 && g < 15 && b < 15) ||
a < 5) {
continue;
}

final rgba = Color.fromARGB(a, r, g, b);
colorCounts[rgba] = (colorCounts[rgba] ?? 0) + 1;
}

var modalColors = <Color>[];
var modalCount = 0;

for (var color in colorCounts.keys) {
final count = colorCounts[color]!;
if (count > modalCount) {
modalCount = count;
modalColors.clear();
}
if (count >= modalCount) {
modalColors.add(color);
}
}

var redTotal = 0.0, greenTotal = 0.0, blueTotal = 0.0;

for (var m in modalColors) {
final r = m.red;
final g = m.green;
final b = m.blue;
if (squared) {
redTotal += r * r.toDouble();
greenTotal += g * g.toDouble();
blueTotal += b * b.toDouble();
} else {
redTotal += r.toDouble();
greenTotal += g.toDouble();
blueTotal += b.toDouble();
}
}

final modalColorsTotal = modalColors.length.toDouble();

int red, green, blue;

if (squared) {
red = sqrt(redTotal / modalColorsTotal).round();
green = sqrt(greenTotal / modalColorsTotal).round();
blue = sqrt(blueTotal / modalColorsTotal).round();
} else {
red = (redTotal / modalColorsTotal).round();
green = (greenTotal / modalColorsTotal).round();
blue = (blueTotal / modalColorsTotal).round();
}

return Color.fromRGBO(red, green, blue, 1.0);
}

/// Returns the same result as [modal], but runs in an isolate.
Future<Color> computeModal(Image img, [bool squared = false]) async {
return await Isolate.run(() => modal(img, squared));
}

/// Returns the same result as [mean], but runs in an isolate.
Future<Color> computeMean(Image img, [bool squared = false]) async {
return await Isolate.run(() => mean(img, squared));
}

Future<Uint8List> _loadNetworkImage(String url) async {
final file = await DefaultCacheManager().getSingleFile(url);
if (file is FileResponse) {
final bytes = await file.readAsBytes();
return Uint8List.fromList(bytes);
}
throw Exception('Failed to load network image: $url');
}

Future<Image> _imageFromUrl(String url, [bool squared = false]) async {
final bytes = await _loadNetworkImage(url);
final codec = await instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();

return frame.image;
}

/// Loads an image from a network url and returns the [mean] average color.
Future<Color> meanFromUrl(String url, [bool squared = false]) async {
final img = await _imageFromUrl(url, squared);
return await mean(img, squared);
}

/// Loads an image from a network url and returns the [modal] average color.
Future<Color> modalFromUrl(String url, [bool squared = false]) async {
final img = await _imageFromUrl(url, squared);
return await modal(img, squared);
}
2 changes: 2 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies:
shimmer: ^3.0.0
intl: ^0.18.1
flutter_staggered_animations: ^1.1.1
flutter_cache_manager: ^3.3.0

flutter:
sdk: flutter
Expand All @@ -33,6 +34,7 @@ dev_dependencies:

dependency_overrides:
collection: ^1.17.2
http: ^1.0.0

flutter:
assets:
Expand Down
116 changes: 116 additions & 0 deletions test/ambiance/algorithm/extract_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import 'dart:ui';

import 'package:flume/flume.dart';
import 'package:test/test.dart';

void main() {
test('mean returns the mean average color from an image', () async {
// Draw a 10x10 image with a single red pixel in the center
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(const Rect.fromLTWH(0, 0, 10, 10),
Paint()..color = const Color(0xff0000ff));
final picture = recorder.endRecording();
final img = await picture.toImage(10, 10);
final color = await mean(img);
expect(color, const Color(0xff0000ff));
});

test('mean ignores very light values', () async {
// Draw a 10x10 image with a white background and a single blue pixel in the
// center

final recorder = PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(const Rect.fromLTWH(0, 0, 10, 10),
Paint()..color = const Color(0xffffffff));

canvas.drawRect(const Rect.fromLTWH(4, 4, 2, 2),
Paint()..color = const Color(0xff0000ff));
final picture = recorder.endRecording();
final img = await picture.toImage(10, 10);
final color = await mean(img);
expect(color, const Color(0xff0000ff));
});

test('mean ignores very dark values', () async {
// Draw a 10x10 image with a black background and a single blue pixel in the
// center

final recorder = PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(const Rect.fromLTWH(0, 0, 10, 10),
Paint()..color = const Color(0xff000000));

canvas.drawRect(const Rect.fromLTWH(4, 4, 2, 2),
Paint()..color = const Color(0xff0000ff));
final picture = recorder.endRecording();
final img = await picture.toImage(10, 10);
final color = await mean(img);
expect(color, const Color(0xff0000ff));
});

test('mean mixes colors as expected', () async {
// Draw a 10x0 image with half blue, half red
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(const Rect.fromLTWH(0, 0, 10, 10),
Paint()..color = const Color(0xff0000ff));
canvas.drawRect(const Rect.fromLTWH(5, 0, 10, 10),
Paint()..color = const Color(0xffff0000));
final picture = recorder.endRecording();
final img = await picture.toImage(10, 10);
final color = await mean(img);
expect(color, const Color(0xff800080));
});

test('modal returns the mode average color from an image', () async {
// Draw a 10x10 image with 51% blue and 49% red
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(const Rect.fromLTWH(0, 0, 10, 10),
Paint()..color = const Color(0xff0000ff));

canvas.drawRect(const Rect.fromLTWH(0, 0, 4.9, 4.9),
Paint()..color = const Color(0xffff0000));

final picture = recorder.endRecording();
final img = await picture.toImage(10, 10);
final color = await modal(img);
expect(color, const Color(0xff0000ff));
});

test('modal ignores very light values', () async {
// Draw a 10x10 image with a white background and a single blue pixel in the
// center

final recorder = PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(const Rect.fromLTWH(0, 0, 10, 10),
Paint()..color = const Color(0xffffffff));

canvas.drawRect(const Rect.fromLTWH(4, 4, 2, 2),
Paint()..color = const Color(0xff0000ff));
final picture = recorder.endRecording();
final img = await picture.toImage(10, 10);
final color = await modal(img);
expect(color, const Color(0xff0000ff));
});

test('modal ignores very dark values', () async {
// Draw a 10x10 image with a black background and a single blue pixel in the
// center

final recorder = PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(const Rect.fromLTWH(0, 0, 10, 10),
Paint()..color = const Color(0xff000000));

canvas.drawRect(const Rect.fromLTWH(4, 4, 2, 2),
Paint()..color = const Color(0xff0000ff));
final picture = recorder.endRecording();
final img = await picture.toImage(10, 10);
final color = await modal(img);
expect(color, const Color(0xff0000ff));
});
}

1 comment on commit 7c24918

@vercel
Copy link

@vercel vercel bot commented on 7c24918 Jun 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.