зеркало из https://github.com/mozilla/gecko-dev.git
393 строки
13 KiB
JavaScript
393 строки
13 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
importScripts("ClusterLib.js", "ColorConversion.js");
|
|
|
|
// Offsets in the ImageData pixel array to reach pixel colors
|
|
const PIXEL_RED = 0;
|
|
const PIXEL_GREEN = 1;
|
|
const PIXEL_BLUE = 2;
|
|
const PIXEL_ALPHA = 3;
|
|
|
|
// Number of components in one ImageData pixel (RGBA)
|
|
const NUM_COMPONENTS = 4;
|
|
|
|
// Shift a color represented as a 24 bit integer by N bits to get a component
|
|
const RED_SHIFT = 16;
|
|
const GREEN_SHIFT = 8;
|
|
|
|
// Only run the N most frequent unique colors through the clustering algorithm
|
|
// Images with more than this many unique colors will have reduced accuracy.
|
|
const MAX_COLORS_TO_MERGE = 500;
|
|
|
|
// Each cluster of colors has a mean color in the Lab color space.
|
|
// If the euclidean distance between the means of two clusters is greater
|
|
// than or equal to this threshold, they won't be merged.
|
|
const MERGE_THRESHOLD = 12;
|
|
|
|
// The highest the distance handicap can be for large clusters
|
|
const MAX_SIZE_HANDICAP = 5;
|
|
// If the handicap is below this number, it is cut off to zero
|
|
const SIZE_HANDICAP_CUTOFF = 2;
|
|
|
|
// If potential background colors deviate from the mean background color by
|
|
// this threshold or greater, finding a background color will fail
|
|
const BACKGROUND_THRESHOLD = 10;
|
|
|
|
// Alpha component of colors must be larger than this in order to make it into
|
|
// the clustering algorithm or be considered a background color (0 - 255).
|
|
const MIN_ALPHA = 25;
|
|
|
|
// The euclidean distance in the Lab color space under which merged colors
|
|
// are weighted lower for being similar to the background color
|
|
const BACKGROUND_WEIGHT_THRESHOLD = 15;
|
|
|
|
// The range in which color chroma differences will affect desirability.
|
|
// Colors with chroma outside of the range take on the desirability of
|
|
// their nearest extremes. Should be roughly 0 - 150.
|
|
const CHROMA_WEIGHT_UPPER = 90;
|
|
const CHROMA_WEIGHT_LOWER = 1;
|
|
const CHROMA_WEIGHT_MIDDLE = (CHROMA_WEIGHT_UPPER + CHROMA_WEIGHT_LOWER) / 2;
|
|
|
|
/**
|
|
* When we receive a message from the outside world, find the representative
|
|
* colors of the given image. The colors will be posted back to the caller
|
|
* through the "colors" property on the event data object as an array of
|
|
* integers. Colors of lower indices are more representative.
|
|
* This array can be empty if this worker can't find a color.
|
|
*
|
|
* @param event
|
|
* A MessageEvent whose data should have the following properties:
|
|
* imageData - A DOM ImageData instance to analyze
|
|
* maxColors - The maximum number of representative colors to find,
|
|
* defaults to 1 if not provided
|
|
*/
|
|
onmessage = function(event) {
|
|
let imageData = event.data.imageData;
|
|
let pixels = imageData.data;
|
|
let width = imageData.width;
|
|
let height = imageData.height;
|
|
let maxColors = event.data.maxColors;
|
|
if (typeof(maxColors) != "number") {
|
|
maxColors = 1;
|
|
}
|
|
|
|
let allColors = getColors(pixels, width, height);
|
|
|
|
// Only merge top colors by frequency for speed.
|
|
let mergedColors = mergeColors(allColors.slice(0, MAX_COLORS_TO_MERGE),
|
|
width * height, MERGE_THRESHOLD);
|
|
|
|
let backgroundColor = getBackgroundColor(pixels, width, height);
|
|
|
|
mergedColors = mergedColors.map(function(cluster) {
|
|
// metadata holds a bunch of information about the color represented by
|
|
// this cluster
|
|
let metadata = cluster.item;
|
|
|
|
// the basis of color desirability is how much of the image the color is
|
|
// responsible for, but we'll need to weigh this number differently
|
|
// depending on other factors
|
|
metadata.desirability = metadata.ratio;
|
|
let weight = 1;
|
|
|
|
// if the color is close to the background color, we don't want it
|
|
if (backgroundColor != null) {
|
|
let backgroundDistance = labEuclidean(metadata.mean, backgroundColor);
|
|
if (backgroundDistance < BACKGROUND_WEIGHT_THRESHOLD) {
|
|
weight = backgroundDistance / BACKGROUND_WEIGHT_THRESHOLD;
|
|
}
|
|
}
|
|
|
|
// prefer more interesting colors, but don't knock low chroma colors
|
|
// completely out of the running (lower bound), and we don't really care
|
|
// if a color is slightly more intense than another on the higher end
|
|
let chroma = labChroma(metadata.mean);
|
|
if (chroma < CHROMA_WEIGHT_LOWER) {
|
|
chroma = CHROMA_WEIGHT_LOWER;
|
|
} else if (chroma > CHROMA_WEIGHT_UPPER) {
|
|
chroma = CHROMA_WEIGHT_UPPER;
|
|
}
|
|
weight *= chroma / CHROMA_WEIGHT_MIDDLE;
|
|
|
|
metadata.desirability *= weight;
|
|
return metadata;
|
|
});
|
|
|
|
// only send back the most desirable colors
|
|
mergedColors.sort(function(a, b) {
|
|
return b.desirability - a.desirability;
|
|
});
|
|
mergedColors = mergedColors.map(function(metadata) {
|
|
return metadata.color;
|
|
}).slice(0, maxColors);
|
|
postMessage({ colors: mergedColors });
|
|
};
|
|
|
|
/**
|
|
* Given the pixel data and dimensions of an image, return an array of objects
|
|
* associating each unique color and its frequency in the image, sorted
|
|
* descending by frequency. Sufficiently transparent colors are ignored.
|
|
*
|
|
* @param pixels
|
|
* Pixel data array for the image to get colors from (ImageData.data).
|
|
* @param width
|
|
* Width of the image, in # of pixels.
|
|
* @param height
|
|
* Height of the image, in # of pixels.
|
|
*
|
|
* @return An array of objects with color and freq properties, sorted
|
|
* descending by freq
|
|
*/
|
|
function getColors(pixels, width, height) {
|
|
let colorFrequency = {};
|
|
for (let x = 0; x < width; x++) {
|
|
for (let y = 0; y < height; y++) {
|
|
let offset = (x * NUM_COMPONENTS) + (y * NUM_COMPONENTS * width);
|
|
|
|
if (pixels[offset + PIXEL_ALPHA] < MIN_ALPHA) {
|
|
continue;
|
|
}
|
|
|
|
let color = pixels[offset + PIXEL_RED] << RED_SHIFT
|
|
| pixels[offset + PIXEL_GREEN] << GREEN_SHIFT
|
|
| pixels[offset + PIXEL_BLUE];
|
|
|
|
if (color in colorFrequency) {
|
|
colorFrequency[color]++;
|
|
} else {
|
|
colorFrequency[color] = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
let colors = [];
|
|
for (var color in colorFrequency) {
|
|
colors.push({ color: +color, freq: colorFrequency[+color] });
|
|
}
|
|
colors.sort(descendingFreqSort);
|
|
return colors;
|
|
}
|
|
|
|
/**
|
|
* Given an array of objects from getColors, the number of pixels in the
|
|
* image, and a merge threshold, run the clustering algorithm on the colors
|
|
* and return the set of merged clusters.
|
|
*
|
|
* @param colorFrequencies
|
|
* An array of objects from getColors to cluster
|
|
* @param numPixels
|
|
* The number of pixels in the image
|
|
* @param threshold
|
|
* The maximum distance between two clusters for which those clusters
|
|
* can be merged.
|
|
*
|
|
* @return An array of merged clusters
|
|
*
|
|
* @see clusterlib.hcluster
|
|
* @see getColors
|
|
*/
|
|
function mergeColors(colorFrequencies, numPixels, threshold) {
|
|
let items = colorFrequencies.map(function(colorFrequency) {
|
|
let color = colorFrequency.color;
|
|
let freq = colorFrequency.freq;
|
|
return {
|
|
mean: rgb2lab(color >> RED_SHIFT, color >> GREEN_SHIFT & 0xff,
|
|
color & 0xff),
|
|
// the canonical color of the cluster
|
|
// (one w/ highest freq or closest to mean)
|
|
color: color,
|
|
colors: [color],
|
|
highFreq: freq,
|
|
highRatio: freq / numPixels,
|
|
// the individual color w/ the highest frequency in this cluster
|
|
highColor: color,
|
|
// ratio of image taken up by colors in this cluster
|
|
ratio: freq / numPixels,
|
|
freq: freq,
|
|
};
|
|
});
|
|
|
|
let merged = clusterlib.hcluster(items, distance, merge, threshold);
|
|
return merged;
|
|
}
|
|
|
|
function descendingFreqSort(a, b) {
|
|
return b.freq - a.freq;
|
|
}
|
|
|
|
/**
|
|
* Given two items for a pair of clusters (as created in mergeColors above),
|
|
* determine the distance between them so we know if we should merge or not.
|
|
* Uses the euclidean distance between their mean colors in the lab color
|
|
* space, weighted so larger items are harder to merge.
|
|
*
|
|
* @param item1
|
|
* The first item to compare
|
|
* @param item2
|
|
* The second item to compare
|
|
*
|
|
* @return The distance between the two items
|
|
*/
|
|
function distance(item1, item2) {
|
|
// don't cluster large blocks of color unless they're really similar
|
|
let minRatio = Math.min(item1.ratio, item2.ratio);
|
|
let dist = labEuclidean(item1.mean, item2.mean);
|
|
let handicap = Math.min(MAX_SIZE_HANDICAP, dist * minRatio);
|
|
if (handicap <= SIZE_HANDICAP_CUTOFF) {
|
|
handicap = 0;
|
|
}
|
|
return dist + handicap;
|
|
}
|
|
|
|
/**
|
|
* Find the euclidean distance between two colors in the Lab color space.
|
|
*
|
|
* @param color1
|
|
* The first color to compare
|
|
* @param color2
|
|
* The second color to compare
|
|
*
|
|
* @return The euclidean distance between the two colors
|
|
*/
|
|
function labEuclidean(color1, color2) {
|
|
return Math.sqrt(
|
|
Math.pow(color2.lightness - color1.lightness, 2)
|
|
+ Math.pow(color2.a - color1.a, 2)
|
|
+ Math.pow(color2.b - color1.b, 2));
|
|
}
|
|
|
|
/**
|
|
* Given items from two clusters we know are appropriate for merging,
|
|
* merge them together into a third item such that its metadata describes both
|
|
* input items. The "color" property is set to the color in the new item that
|
|
* is closest to its mean color.
|
|
*
|
|
* @param item1
|
|
* The first item to merge
|
|
* @param item2
|
|
* The second item to merge
|
|
*
|
|
* @return An item that represents the merging of the given items
|
|
*/
|
|
function merge(item1, item2) {
|
|
let lab1 = item1.mean;
|
|
let lab2 = item2.mean;
|
|
|
|
/* algorithm tweak point - weighting the mean of the cluster */
|
|
let num1 = item1.freq;
|
|
let num2 = item2.freq;
|
|
|
|
let total = num1 + num2;
|
|
|
|
let mean = {
|
|
lightness: (lab1.lightness * num1 + lab2.lightness * num2) / total,
|
|
a: (lab1.a * num1 + lab2.a * num2) / total,
|
|
b: (lab1.b * num1 + lab2.b * num2) / total
|
|
};
|
|
|
|
let colors = item1.colors.concat(item2.colors);
|
|
|
|
// get the canonical color of the new cluster
|
|
let color;
|
|
let avgFreq = colors.length / (item1.freq + item2.freq);
|
|
if ((item1.highFreq > item2.highFreq) && (item1.highFreq > avgFreq * 2)) {
|
|
color = item1.highColor;
|
|
} else if (item2.highFreq > avgFreq * 2) {
|
|
color = item2.highColor;
|
|
} else {
|
|
// if there's no stand-out color
|
|
let minDist = Infinity, closest = 0;
|
|
for (let i = 0; i < colors.length; i++) {
|
|
let color = colors[i];
|
|
let lab = rgb2lab(color >> RED_SHIFT, color >> GREEN_SHIFT & 0xff,
|
|
color & 0xff);
|
|
let dist = labEuclidean(lab, mean);
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
closest = i;
|
|
}
|
|
}
|
|
color = colors[closest];
|
|
}
|
|
|
|
const higherItem = item1.highFreq > item2.highFreq ? item1 : item2;
|
|
|
|
return {
|
|
mean: mean,
|
|
color: color,
|
|
highFreq: higherItem.highFreq,
|
|
highColor: higherItem.highColor,
|
|
highRatio: higherItem.highRatio,
|
|
ratio: item1.ratio + item2.ratio,
|
|
freq: item1.freq + item2.freq,
|
|
colors: colors,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Find the background color of the given image.
|
|
*
|
|
* @param pixels
|
|
* The pixel data for the image (an array of component integers)
|
|
* @param width
|
|
* The width of the image
|
|
* @param height
|
|
* The height of the image
|
|
*
|
|
* @return The background color of the image as a Lab object, or null if we
|
|
* can't determine the background color
|
|
*/
|
|
function getBackgroundColor(pixels, width, height) {
|
|
// we'll assume that if the four corners are roughly the same color,
|
|
// then that's the background color
|
|
let coordinates = [[0, 0], [width - 1, 0], [width - 1, height - 1],
|
|
[0, height - 1]];
|
|
|
|
// find the corner colors in LAB
|
|
let cornerColors = [];
|
|
for (let i = 0; i < coordinates.length; i++) {
|
|
let offset = (coordinates[i][0] * NUM_COMPONENTS)
|
|
+ (coordinates[i][1] * NUM_COMPONENTS * width);
|
|
if (pixels[offset + PIXEL_ALPHA] < MIN_ALPHA) {
|
|
// we can't make very accurate judgements below this opacity
|
|
continue;
|
|
}
|
|
cornerColors.push(rgb2lab(pixels[offset + PIXEL_RED],
|
|
pixels[offset + PIXEL_GREEN],
|
|
pixels[offset + PIXEL_BLUE]));
|
|
}
|
|
|
|
// we want at least two points at acceptable alpha levels
|
|
if (cornerColors.length <= 1) {
|
|
return null;
|
|
}
|
|
|
|
// find the average color among the corners
|
|
let averageColor = { lightness: 0, a: 0, b: 0 };
|
|
cornerColors.forEach(function(color) {
|
|
for (let i in color) {
|
|
averageColor[i] += color[i];
|
|
}
|
|
});
|
|
for (let i in averageColor) {
|
|
averageColor[i] /= cornerColors.length;
|
|
}
|
|
|
|
// if we have fewer points due to low alpha, they need to be closer together
|
|
let threshold = BACKGROUND_THRESHOLD
|
|
* (cornerColors.length / coordinates.length);
|
|
|
|
// if any of the corner colors deviate enough from the average, they aren't
|
|
// similar enough to be considered the background color
|
|
for (let cornerColor of cornerColors) {
|
|
if (labEuclidean(cornerColor, averageColor) > threshold) {
|
|
return null;
|
|
}
|
|
}
|
|
return averageColor;
|
|
}
|