From 7c24918a244a6ca5316a409ba2ffd6814e358879 Mon Sep 17 00:00:00 2001 From: Arne Molland Date: Fri, 9 Jun 2023 01:55:38 +0200 Subject: [PATCH] ambiance: add functions for color extraction --- lib/src/ambiance.dart | 1 + lib/src/ambiance/algorithm/extract.dart | 197 ++++++++++++++++++++++ pubspec.yaml | 2 + test/ambiance/algorithm/extract_test.dart | 116 +++++++++++++ 4 files changed, 316 insertions(+) create mode 100644 lib/src/ambiance/algorithm/extract.dart create mode 100644 test/ambiance/algorithm/extract_test.dart diff --git a/lib/src/ambiance.dart b/lib/src/ambiance.dart index 57b2101..5b4f6ae 100644 --- a/lib/src/ambiance.dart +++ b/lib/src/ambiance.dart @@ -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'; diff --git a/lib/src/ambiance/algorithm/extract.dart b/lib/src/ambiance/algorithm/extract.dart new file mode 100644 index 0000000..959584c --- /dev/null +++ b/lib/src/ambiance/algorithm/extract.dart @@ -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 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 modal(Image img, [bool squared = false]) async { + final width = img.width; + final height = img.height; + final size = width * height; + final colorCounts = {}; + + 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 = []; + 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 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 computeMean(Image img, [bool squared = false]) async { + return await Isolate.run(() => mean(img, squared)); +} + +Future _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 _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 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 modalFromUrl(String url, [bool squared = false]) async { + final img = await _imageFromUrl(url, squared); + return await modal(img, squared); +} diff --git a/pubspec.yaml b/pubspec.yaml index db3d765..31428d6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 @@ -33,6 +34,7 @@ dev_dependencies: dependency_overrides: collection: ^1.17.2 + http: ^1.0.0 flutter: assets: diff --git a/test/ambiance/algorithm/extract_test.dart b/test/ambiance/algorithm/extract_test.dart new file mode 100644 index 0000000..ae9f8de --- /dev/null +++ b/test/ambiance/algorithm/extract_test.dart @@ -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)); + }); +}