tenfourfox/toolkit/components/places/ColorAnalyzer_worker.js
Cameron Kaiser c9b2922b70 hello FPR
2017-04-19 00:56:45 -07:00

393 lines
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 ? b.desirability - a.desirability : b.color - a.color;
});
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 ? b.freq - a.freq : b.color - a.color;
}
/**
* 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;
}