Bug 1316603 - add a findDominantColors() method to the ColorAnalyzer class to extract dominant colors from an image to use as theme colors. r?jaws draft
authorMike de Boer <mdeboer@mozilla.com>
Fri, 18 Nov 2016 17:34:56 +0100
changeset 441224 3bca0c25f98b6adaf04114957321e5b1df2a3890
parent 441223 26b31d10e8ccc0f257b6f2b3c20ef7fbe1f6819c
child 537516 20d05b2294fd130fe210159a3d532ec4305b7aa4
push id36381
push usermdeboer@mozilla.com
push dateFri, 18 Nov 2016 16:35:35 +0000
reviewersjaws
bugs1316603
milestone53.0a1
Bug 1316603 - add a findDominantColors() method to the ColorAnalyzer class to extract dominant colors from an image to use as theme colors. r?jaws MozReview-Commit-ID: 5zyQ6wIHmhk
browser/components/extensions/ext-theme.js
toolkit/components/places/ColorAnalyzer.js
toolkit/components/places/ColorAnalyzer_worker.js
toolkit/components/places/Palette.js
toolkit/components/places/moz.build
toolkit/components/places/mozIColorAnalyzer.idl
toolkit/modules/Color.jsm
--- a/browser/components/extensions/ext-theme.js
+++ b/browser/components/extensions/ext-theme.js
@@ -1,12 +1,13 @@
 "use strict";
 
 Cu.import("resource://gre/modules/AppConstants.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 // WeakMap[Extension -> Theme]
 let themeMap = new WeakMap();
 
 let styleSheetService = Components.classes["@mozilla.org/content/style-sheet-service;1"]
                                   .getService(Components.interfaces.nsIStyleSheetService);
 let ioService = Components.classes["@mozilla.org/network/io-service;1"]
                           .getService(Components.interfaces.nsIIOService);
@@ -417,39 +418,44 @@ class Theme {
     this.loadContentScript();
     this.baseURI = baseURI;
     this.userSheetURI = null;
     this.LWTStyles = {};
     this.aboutHomeCSSVars = {};
     this.browserStyleOverrides = {};
     this.cssVars = {};
     this.ntp_video = null;
-    this.load(manifest.theme);
-    this.render();
+    this.load(manifest.theme, () => this.render());
   }
 
-  load(theme) {
+  load(theme, callback) {
     // Order of sections matters because gradients apply on top of colors.
     if (theme.images) {
-      this.loadImages(theme.images);
-    }
-    if (theme.colors) {
-      this.loadColors(theme.colors);
-    }
-    if (theme.tints) {
-      this.loadTints(theme.tints);
+      this.loadImages(theme.images, theme, () => continueLoad.call(this));
+    } else {
+      continueLoad.call(this);
     }
-    if (theme.gradients) {
-      this.loadGradients(theme.gradients);
-    }
-    if (theme.properties) {
-      this.loadProperties(theme.properties);
-    }
-    if (theme.icons) {
-      this.loadIcons(theme.icons);
+
+    function continueLoad() {
+      if (theme.colors) {
+        this.loadColors(theme.colors);
+      }
+      if (theme.tints) {
+        this.loadTints(theme.tints);
+      }
+      if (theme.gradients) {
+        this.loadGradients(theme.gradients);
+      }
+      if (theme.properties) {
+        this.loadProperties(theme.properties);
+      }
+      if (theme.icons) {
+        this.loadIcons(theme.icons);
+      }
+      callback();
     }
   }
 
   loadColors(colors) {
     for (let color of Object.getOwnPropertyNames(colors)) {
       let val = colors[color];
       // Since values are optional, they may be `null`.
       if (val === null) {
@@ -498,22 +504,24 @@ class Theme {
         continue;
       }
       if (kChromeThemeGradientsVarMap.has(gradient)) {
         addToCSSVars.apply(null, [this.cssVars, val].concat(kChromeThemeGradientsVarMap.get(gradient)));
       }
     }
   }
 
-  loadImages(images) {
+  loadImages(images, fullTheme, callback) {
     // Use a temporary element to filter the CSS values that themes can provide.
     if (!WindowManager.topWindow) {
       return;
     }
 
+    let deferred = false;
+
     let temp = WindowManager.topWindow.document.createElement("temp");
     for (let image of Object.getOwnPropertyNames(images)) {
       let val = images[image];
       if (!val) {
         continue;
       }
       val = this.baseURI.resolve(val);
       let cssURL = 'url("' + val.replace(/"/g, '\\"') + '")';
@@ -524,25 +532,45 @@ class Theme {
             "--page-background-image", ":root");
         } else {
           temp.style.backgroundImage = cssURL;
           addToCSSVars(this.aboutHomeCSSVars, `${temp.style.backgroundImage}`,
             "--page-background-image", ":root");
         }
       } else if (image == "theme_frame" || image == "headerURL") {
         this.LWTStyles.headerURL = val;
+        if (!fullTheme.colors || !Object.keys(fullTheme.colors).length) {
+          deferred = true;
+          const CA = Cc["@mozilla.org/places/colorAnalyzer;1"].getService(Ci.mozIColorAnalyzer);
+          CA.findDominantColors(Services.io.newURI(val, "", null), function(success, color, pal) {
+            if (!success) {
+              callback();
+              return;
+            }
+
+            pal = JSON.parse(pal);
+            fullTheme.colors = {
+              frame: pal.mutedColor,
+              tab_text: pal.vibrantColor
+            };
+            callback();
+          });
+        }
       } else if (image == "theme_toolbar") {
         temp.style.backgroundImage = cssURL;
         addToCSSVars(this.cssVars, `${temp.style.backgroundImage}`,
           "--toolbar-background-image", ":root");
       }
       if (kBrowserOverrideImagesVarMap.has(image)) {
         addToBrowserOverrides(this.browserStyleOverrides, cssURL, kBrowserOverrideImagesVarMap.get(image));
       }
     }
+
+    if (!deferred)
+      callback();
   }
 
   loadProperties(properties) {
     for (let property of Object.getOwnPropertyNames(properties || {})) {
       let val = properties[property];
       // Since values are optional, they may be `null`.
       if (val === null) {
         continue;
@@ -798,16 +826,19 @@ extensions.on("shutdown", (type, extensi
 });
 /* eslint-enable mozilla/balanced-listeners */
 
 extensions.registerSchemaAPI("theme", "addon_parent", context => {
   let {extension} = context;
   return {
     theme: {
       update(details) {
-        let theme = themeMap.get(extension);
-        theme.load(details);
-        theme.render({asUpdate: true});
-        return Promise.resolve();
+        return new Promise(resolve => {
+          let theme = themeMap.get(extension);
+          theme.load(details, () => {
+            theme.render({asUpdate: true});
+            resolve();
+          });
+        });
       },
     },
   };
 });
--- a/toolkit/components/places/ColorAnalyzer.js
+++ b/toolkit/components/places/ColorAnalyzer.js
@@ -23,61 +23,71 @@ function ColorAnalyzer() {
 
   this.worker = new ChromeWorker("resource://gre/modules/ColorAnalyzer_worker.js");
   this.worker.onmessage = this.onWorkerMessage.bind(this);
   this.worker.onerror = this.onWorkerError.bind(this);
 }
 
 ColorAnalyzer.prototype = {
   findRepresentativeColor: function ColorAnalyzer_frc(imageURI, callback) {
+    this.loadImage(imageURI, "representative", callback);
+  },
+
+  findDominantColors(imageURI, callback) {
+    this.loadImage(imageURI, "dominant", callback);
+  },
+
+  loadImage(imageURI, operation, callback) {
     function cleanup() {
       image.removeEventListener("load", loadListener);
       image.removeEventListener("error", errorListener);
     }
     let image = this.hiddenWindowDoc.createElementNS(XHTML_NS, "img");
-    let loadListener = this.onImageLoad.bind(this, image, callback, cleanup);
+    let loadListener = this.onImageLoad.bind(this, image, operation, callback, cleanup);
     let errorListener = this.onImageError.bind(this, image, callback, cleanup);
     image.addEventListener("load", loadListener);
     image.addEventListener("error", errorListener);
     image.src = imageURI.spec;
   },
 
-  onImageLoad: function ColorAnalyzer_onImageLoad(image, callback, cleanup) {
-    if (image.naturalWidth * image.naturalHeight > MAXIMUM_PIXELS) {
+  onImageLoad: function ColorAnalyzer_onImageLoad(image, operation, callback, cleanup) {
+    if (operation == "representative" && image.naturalWidth * image.naturalHeight > MAXIMUM_PIXELS) {
       // this will probably take too long to process - fail
       callback.onComplete(false);
     } else {
       let canvas = this.hiddenWindowDoc.createElementNS(XHTML_NS, "canvas");
       canvas.width = image.naturalWidth;
       canvas.height = image.naturalHeight;
       let ctx = canvas.getContext("2d");
       ctx.drawImage(image, 0, 0);
       this.startJob(ctx.getImageData(0, 0, canvas.width, canvas.height),
-                    callback);
+                    operation, callback);
     }
     cleanup();
   },
 
   onImageError: function ColorAnalyzer_onImageError(image, callback, cleanup) {
     Cu.reportError("ColorAnalyzer: image at " + image.src + " didn't load");
     callback.onComplete(false);
     cleanup();
   },
 
-  startJob: function ColorAnalyzer_startJob(imageData, callback) {
+  startJob: function ColorAnalyzer_startJob(imageData, operation, callback) {
     this.callbacks.push(callback);
-    this.worker.postMessage({ imageData: imageData, maxColors: 1 });
+    this.worker.postMessage({ imageData: imageData, operation, maxColors: 1 });
   },
 
   onWorkerMessage: function ColorAnalyzer_onWorkerMessage(event) {
     // colors can be empty on failure
-    if (event.data.colors.length < 1) {
+    let colors = event.data.colors;
+    let isArray = Array.isArray(colors);
+    if (isArray && colors.length < 1) {
       this.callbacks.shift().onComplete(false);
     } else {
-      this.callbacks.shift().onComplete(true, event.data.colors[0]);
+      this.callbacks.shift().onComplete(true, isArray ? colors[0] : 0, JSON.stringify(colors));
     }
   },
 
   onWorkerError: function ColorAnalyzer_onWorkerError(error) {
     // this shouldn't happen, but just in case
     error.preventDefault();
     Cu.reportError("ColorAnalyzer worker: " + error.message);
     this.callbacks.shift().onComplete(false);
--- a/toolkit/components/places/ColorAnalyzer_worker.js
+++ b/toolkit/components/places/ColorAnalyzer_worker.js
@@ -1,15 +1,16 @@
 /* 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");
+importScripts("ClusterLib.js", "ColorConversion.js",
+  "resource://gre/modules/Color.jsm", "Palette.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)
@@ -61,26 +62,28 @@ const CHROMA_WEIGHT_MIDDLE = (CHROMA_WEI
  *
  * @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;
+  let {imageData, operation, maxColors} = event.data;
+  let {data: pixels, width, height} = imageData;
   if (typeof(maxColors) != "number") {
     maxColors = 1;
   }
 
   let allColors = getColors(pixels, width, height);
+  if (operation == "dominant") {
+    let pal = new Palette(allColors);
+    postMessage({ colors: pal.toJSON() });
+    return;
+  }
 
   // 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) {
@@ -139,39 +142,41 @@ onmessage = function(event) {
  * @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 = {};
+  let intToRgbMap = {};
   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];
+      let rgb = [pixels[offset + PIXEL_RED], pixels[offset + PIXEL_GREEN],
+        pixels[offset + PIXEL_BLUE]];
+      let color = rgb[0] << RED_SHIFT | rgb[1] << GREEN_SHIFT | rgb[2];
+      intToRgbMap[color] = rgb;
 
       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.push({ color: +color, rgb: intToRgbMap[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
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/Palette.js
@@ -0,0 +1,152 @@
+/* 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/. */
+
+// This class is heavily inpired by the Android Palette class:
+// https://developer.android.com/reference/android/support/v7/graphics/Palette.html.
+// License: Apache License, Version 2.0.
+
+"use strict";
+
+const TARGET_DARK_LUMA = 0.26;
+const MAX_DARK_LUMA = 0.45;
+const MIN_LIGHT_LUMA = 0.55;
+const TARGET_LIGHT_LUMA = 0.74;
+
+const MIN_NORMAL_LUMA = 0.3;
+const TARGET_NORMAL_LUMA = 0.5;
+const MAX_NORMAL_LUMA = 0.7;
+
+const TARGET_MUTED_SATURATION = 0.3;
+const MAX_MUTED_SATURATION = 0.4;
+
+const TARGET_VIBRANT_SATURATION = 1;
+const MIN_VIBRANT_SATURATION = 0.35;
+
+const WEIGHT_SATURATION = 3;
+const WEIGHT_LUMA = 6;
+const WEIGHT_POPULATION = 1;
+
+class Palette {
+  constructor(colors) {
+    this.colors = colors;
+    this.highestPopulation = null;
+    this.vibrantColor = null;
+    this.mutedColor = null;
+    this.darkVibrantColor = null;
+    this.darkMutedColor = null;
+    this.lightVibrantColor = null;
+    this.lightMutedColor = null;
+
+    this.highestPopulation = this.findMaxPopulation();
+    this.fillColors();
+  }
+
+  findMaxPopulation() {
+    // The first item in the array of colors has the max freq. (It's sorted.)
+    return new Color(...this.colors[0].rgb, this.colors[0].freq);
+  }
+
+  fillColors() {
+    this.vibrantColor = this.findColor(TARGET_NORMAL_LUMA, MIN_NORMAL_LUMA,
+      MAX_NORMAL_LUMA, TARGET_VIBRANT_SATURATION, MIN_VIBRANT_SATURATION, 1);
+    this.lightVibrantColor = this.findColor(TARGET_LIGHT_LUMA, MIN_LIGHT_LUMA,
+      1, TARGET_VIBRANT_SATURATION, MIN_VIBRANT_SATURATION, 1);
+    this.darkVibrantColor = this.findColor(TARGET_DARK_LUMA, 0, MAX_DARK_LUMA,
+      TARGET_VIBRANT_SATURATION, MIN_VIBRANT_SATURATION, 1);
+    this.mutedColor = this.findColor(TARGET_NORMAL_LUMA, MIN_NORMAL_LUMA,
+      MAX_NORMAL_LUMA, TARGET_MUTED_SATURATION, 0, MAX_MUTED_SATURATION);
+    this.lightMutedColor = this.findColor(TARGET_LIGHT_LUMA, MIN_LIGHT_LUMA, 1,
+      TARGET_MUTED_SATURATION, 0, MAX_MUTED_SATURATION);
+    this.darkMutedColor = this.findColor(TARGET_DARK_LUMA, 0, MAX_DARK_LUMA,
+      TARGET_MUTED_SATURATION, 0, MAX_MUTED_SATURATION);
+
+    // Populate any colors we couldn't find.
+    this.fillEmptyColors();
+  }
+
+  fillEmptyColors() {
+    if (this.vibrantColor === null) {
+      // If we do not have a vibrant color...
+      if (this.darkVibrantColor !== null) {
+        // ...but we do have a dark vibrant, generate the value by modifying the luma
+        let newHsl = [...this.darkVibrantColor.hsl];
+        newHsl[2] = TARGET_NORMAL_LUMA;
+        this.vibrantColor = new Color(...Color.hslToRgb(newHsl));
+      }
+    }
+    if (this.darkVibrantColor === null) {
+      // If we do not have a dark vibrant color...
+      if (this.vibrantColor !== null) {
+        // ...but we do have a vibrant, generate the value by modifying the luma
+        let newHsl = [...this.vibrantColor.hsl];
+        newHsl[2] = TARGET_DARK_LUMA;
+        this.darkVibrantColor = new Color(...Color.hslToRgb(newHsl));
+      }
+    }
+  }
+
+  isColorSelected(color) {
+    return (this.vibrantColor && this.vibrantColor.equals(color)) ||
+      (this.darkVibrantColor && this.darkVibrantColor.equals(color)) ||
+      (this.lightVibrantColor && this.lightVibrantColor.equals(color)) ||
+      (this.mutedColor && this.mutedColor.equals(color)) ||
+      (this.darkMutedColor && this.darkMutedColor.equals(color)) ||
+      (this.lightMutedColor && this.lightMutedColor.equals(color));
+  }
+
+  findColor(targetLuma, minLuma, maxLuma, targetSaturation, minSaturation, maxSaturation) {
+    let max = null;
+    let maxValue = 0;
+    for (let color of this.colors) {
+      color = new Color(...color.rgb, color.freq);
+      let sat = color.s;
+      let luma = color.l;
+      if (sat >= minSaturation && sat <= maxSaturation && luma >= minLuma &&
+          luma <= maxLuma && !this.isColorSelected(color)) {
+        let thisValue = this.createComparisonValue(sat, targetSaturation, luma,
+          targetLuma, color.population);
+        if (max == null || thisValue > maxValue) {
+          max = color;
+          maxValue = thisValue;
+        }
+      }
+    }
+    return max;
+  }
+
+  createComparisonValue(saturation, targetSaturation, luma, targetLuma, population) {
+    return this.weightedMean(
+      this.invertDiff(saturation, targetSaturation), 3,
+      this.invertDiff(luma, targetLuma), 6.5,
+      population / this.highestPopulation, 0.5
+    );
+  }
+
+  weightedMean(...values) {
+    let sum = 0;
+    let sumWeight = 0;
+    for (let i = 0; i < values.length; i += 2) {
+      let value = values[i];
+      let weight = values[i + 1];
+      sum += value * weight;
+      sumWeight += weight;
+    }
+    return sum / sumWeight;
+  }
+
+  invertDiff(value, targetValue) {
+    return 1 - Math.abs(value - targetValue);
+  }
+
+  toJSON() {
+    return {
+      vibrantColor: this.vibrantColor.rgb,
+      darkVibrantColor: this.darkVibrantColor.rgb,
+      lightVibrantColor: this.lightVibrantColor.rgb,
+      mutedColor: this.mutedColor.rgb,
+      darkMutedColor: this.darkMutedColor.rgb,
+      lightMutedColor: this.lightMutedColor.rgb
+    };
+  }
+}
--- a/toolkit/components/places/moz.build
+++ b/toolkit/components/places/moz.build
@@ -61,16 +61,17 @@ if CONFIG['MOZ_PLACES']:
         'BookmarkHTMLUtils.jsm',
         'BookmarkJSONUtils.jsm',
         'Bookmarks.jsm',
         'ClusterLib.js',
         'ColorAnalyzer_worker.js',
         'ColorConversion.js',
         'ExtensionSearchHandler.jsm',
         'History.jsm',
+        'Palette.js',
         'PlacesBackups.jsm',
         'PlacesDBUtils.jsm',
         'PlacesRemoteTabsAutocompleteProvider.jsm',
         'PlacesSearchAutocompleteProvider.jsm',
         'PlacesSyncUtils.jsm',
         'PlacesTransactions.jsm',
         'PlacesUtils.jsm',
     ]
--- a/toolkit/components/places/mozIColorAnalyzer.idl
+++ b/toolkit/components/places/mozIColorAnalyzer.idl
@@ -17,20 +17,20 @@ interface mozIRepresentativeColorCallbac
    *        Analysis can fail if the image is transparent, imageURI doesn't
    *        resolve to a valid image, or the image is too big.
    *
    * @param color
    *        The representative color as an integer in RGB form.
    *        e.g. 0xFF0102 == rgb(255,1,2)
    *        If success is false, color is not provided.
    */
-  void onComplete(in boolean success, [optional] in unsigned long color);
+  void onComplete(in boolean success, [optional] in unsigned long color, [optional] in jsval colors);
 };
 
-[scriptable, uuid(d056186c-28a0-494e-aacc-9e433772b143)]
+[scriptable, uuid(0ce30b36-3f67-452b-a91f-0f52b04c7720)]
 interface mozIColorAnalyzer : nsISupports
 {
   /**
    * Given an image URI, find the most representative color for that image
    * based on the frequency of each color.  Preference is given to colors that
    * are more interesting.  Avoids the background color if it can be
    * discerned.  Ignores sufficiently transparent colors.
    *
@@ -44,9 +44,24 @@ interface mozIColorAnalyzer : nsISupport
    *        that will load when setting the src attribute of a DOM img element
    *        should work.
    * @param callback
    *        Function to call when the representative color is found or an
    *        error occurs.
    */
   void findRepresentativeColor(in nsIURI imageURI,
                                in mozIRepresentativeColorCallback callback);
+
+  /**
+   * Given an image URI, find the most dominant colors for that image based on
+   * the frequency of each color.
+   *
+   * @param imageURI
+   *        A URI pointing to the image - ideally a data: URI, but any scheme
+   *        that will load when setting the src attribute of a DOM img element
+   *        should work.
+   * @param callback
+   *        Function to call when the representative color is found or an
+   *        error occurs.
+   */
+  void findDominantColors(in nsIURI imageURI,
+                          in mozIRepresentativeColorCallback callback);
 };
--- a/toolkit/modules/Color.jsm
+++ b/toolkit/modules/Color.jsm
@@ -9,24 +9,48 @@ this.EXPORTED_SYMBOLS = ["Color"];
 /**
  * Color class, which describes a color.
  * In the future, this object may be extended to allow for conversions between
  * different color formats and notations, support transparency.
  *
  * @param {Number} r Red color component
  * @param {Number} g Green color component
  * @param {Number} b Blue color component
+ * @param {Number} population
  */
-function Color(r, g, b) {
+function Color(r, g, b, population) {
   this.r = r;
   this.g = g;
   this.b = b;
+  this.population = population || 0;
 }
 
 Color.prototype = {
+  get hsl() {
+    if (!this._hsl)
+      this._hsl = Color.rgbToHsl(this.r, this.g, this.b);
+    return this._hsl;
+  },
+
+  get h() {
+    return this.hsl[0];
+  },
+
+  get s() {
+    return this.hsl[1];
+  },
+
+  get l() {
+    return this.hsl[2];
+  },
+
+  get rgb() {
+    return [this.r, this.g, this.b];
+  },
+
   /**
    * Formula from W3C's WCAG 2.0 spec's relative luminance, section 1.4.1,
    * http://www.w3.org/TR/WCAG20/.
    *
    * @return {Number} Relative luminance, represented as number between 0 and 1.
    */
   get relativeLuminance() {
     let colorArr = [this.r, this.b, this.g].map(color => {
@@ -76,10 +100,120 @@ Color.prototype = {
    *
    * @param  {Color} otherColor Color instance to calculate the contrast with
    * @return {Boolean}
    */
   isContrastRatioAcceptable(otherColor) {
     // Note: this is a high enough value to be considered as 'high contrast',
     //       but was decided upon empirically.
     return this.contrastRatio(otherColor) > 3;
+  },
+
+  equals(color) {
+    if (!(color instanceof Color))
+      return false;
+    return this.r == color.r && this.g == color.g && this.b == color.b;
+  },
+
+  toString() {
+    return `rgb( ${this.r}, ${this.g}, ${this.b} ) :: hsl( ${this.h}, ${this.s},` +
+      ` ${this.l} ) :: population: ${this.population}`;
   }
 };
+
+// Static methods.
+
+function roundTo(number, digits) {
+  const multiplier = Math.pow(10, digits);
+  return Math.round(number * multiplier) / multiplier;
+}
+
+/**
+ * Convert rgb value to hsl
+ *
+ * @param {Number} r
+ * @param {Number} g
+ * @param {Number} b
+ * @return {Array} Array of hsl values.
+ */
+Color.rgbToHsl = function(r, g, b) {
+  r /= 255;
+  g /= 255;
+  b /= 255;
+
+  let max = Math.max(r, g, b);
+  let min = Math.min(r, g, b);
+  let h, s;
+  let l = (max + min) / 2;
+
+  if (max == min) {
+    h = s = 0;
+  } else {
+    let d = max - min;
+    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+
+    switch (max) {
+      case r:
+        h = (g - b) / d + (g < b ? 6 : 0);
+        break;
+      case g:
+        h = (b - r) / d + 2;
+        break;
+      case b:
+        h = (r - g) / d + 4;
+        break;
+    }
+
+    h /= 6;
+  }
+
+  return [h, s, l];
+};
+
+/**
+ * Helper function for Color.hslToRgb().
+ *
+ * @param {Number} p
+ * @param {Number} q
+ * @param {Number} t
+ * @return {Number}
+ */
+function hueToRgb(p, q, t) {
+  if (t < 0)
+    t += 1;
+  if (t > 1)
+    t -= 1;
+  if (t < 1 / 6)
+    return p + (q - p) * 6 * t;
+  if (t < 1 / 2)
+    return q;
+  if (t < 2 / 3)
+    return p + (q - p) * (2 / 3 - t) * 6;
+  return p;
+}
+
+/**
+ * Converts an HSL color value to RGB. Conversion formula
+ * adapted from http://en.wikipedia.org/wiki/HSL_color_space.
+ * Assumes h, s, and l are contained in the set [0, 1] and
+ * returns r, g, and b in the set [0, 255].
+ *
+ * @param {Number} h The hue
+ * @param {Number} s The saturation
+ * @param {Number} l The lightness
+ * @return {Array} The RGB representation
+ */
+Color.hslToRgb = function(h, s, l) {
+  let r, g, b;
+
+  if (s == 0) {
+    r = g = b = l; // achromatic
+  } else {
+    let q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+    let p = 2 * l - q;
+
+    r = hueToRgb(p, q, h + 1 / 3);
+    g = hueToRgb(p, q, h);
+    b = hueToRgb(p, q, h - 1 / 3);
+  }
+
+  return [r * 255, g * 255, b * 255];
+};