Raw File
ColorAnalyzer_worker.js
/* 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;
}
back to top