rebaseline_server: use new intermediate JSON format as described in https://goto.google.com/ChangingRbsJson

There are still some places in the frontend (HTML+Javascript) code where
we assume we are handling expectations vs actuals, but there are just a few
and we should be able to remove them easily in a coming CL.
At that point, the frontend will work just as well for displaying any set
of image pairs.

BUG=skia:1919
NOTRY=True
R=rmistry@google.com

Author: epoger@google.com

Review URL: https://codereview.chromium.org/178253010

git-svn-id: http://skia.googlecode.com/svn/trunk@13598 2bbb7eff-a529-9590-31e7-b0007b416f81
diff --git a/gm/gm_json.py b/gm/gm_json.py
index 1aac663..6f4b324 100644
--- a/gm/gm_json.py
+++ b/gm/gm_json.py
@@ -15,12 +15,14 @@
 import io
 import json
 import os
+import posixpath
+import re
 
 
 # Key strings used in GM results JSON files (both expected-results.json and
 # actual-results.json).
 #
-# These constants must be kept in sync with the kJsonKey_ constants in
+# NOTE: These constants must be kept in sync with the kJsonKey_ constants in
 # gm_expectations.cpp !
 
 
@@ -93,6 +95,14 @@
 # Pattern used to assemble each image's filename
 IMAGE_FILENAME_PATTERN = '(.+)_(.+)\.png'  # matches (testname, config)
 
+# Pattern used to create image URLs, relative to some base URL.
+GM_RELATIVE_URL_FORMATTER = '%s/%s/%s.png' # pass in (hash_type, test_name,
+                                           #          hash_digest)
+GM_RELATIVE_URL_PATTERN = '(.+)/(.+)/(.+).png' # matches (hash_type, test_name,
+                                               #          hash_digest)
+GM_RELATIVE_URL_RE = re.compile(GM_RELATIVE_URL_PATTERN)
+
+
 def CreateGmActualUrl(test_name, hash_type, hash_digest,
                       gm_actuals_root_url=GM_ACTUALS_ROOT_HTTP_URL):
   """Return the URL we can use to download a particular version of
@@ -104,10 +114,40 @@
   hash_digest: the hash digest of the image to retrieve
   gm_actuals_root_url: root url where actual images are stored
   """
-  # TODO(epoger): Maybe use url_or_path.join() so that, for testing, this can
-  # return either a URL or a local filepath?
-  return '%s/%s/%s/%s.png' % (gm_actuals_root_url, hash_type, test_name,
-                              hash_digest)
+  return posixpath.join(
+      gm_actuals_root_url, CreateGmRelativeUrl(
+          test_name=test_name, hash_type=hash_type, hash_digest=hash_digest))
+
+
+def CreateGmRelativeUrl(test_name, hash_type, hash_digest):
+  """Returns a relative URL pointing at a test result's image.
+
+  Returns the URL we can use to download a particular version of
+  the actually-generated image for this particular GM test,
+  relative to the URL root.
+
+  Args:
+    test_name: name of the test, e.g. 'perlinnoise'
+    hash_type: string indicating the hash type used to generate hash_digest,
+               e.g. JSONKEY_HASHTYPE_BITMAP_64BITMD5
+    hash_digest: the hash digest of the image to retrieve
+  """
+  return GM_RELATIVE_URL_FORMATTER % (hash_type, test_name, hash_digest)
+
+
+def SplitGmRelativeUrl(url):
+  """Splits the relative URL into (test_name, hash_type, hash_digest) tuple.
+
+  This is the inverse of CreateGmRelativeUrl().
+
+  Args:
+    url: a URL generated with CreateGmRelativeUrl().
+
+  Returns: (test_name, hash_type, hash_digest) tuple.
+  """
+  hash_type, test_name, hash_digest = GM_RELATIVE_URL_RE.match(url).groups()
+  return (test_name, hash_type, hash_digest)
+
 
 def LoadFromString(file_contents):
   """Loads the JSON summary written out by the GM tool.
@@ -119,6 +159,7 @@
   json_dict = json.loads(file_contents)
   return json_dict
 
+
 def LoadFromFile(file_path):
   """Loads the JSON summary written out by the GM tool.
      Returns a dictionary keyed by the values listed as JSONKEY_ constants
@@ -126,6 +167,7 @@
   file_contents = open(file_path, 'r').read()
   return LoadFromString(file_contents)
 
+
 def WriteToFile(json_dict, file_path):
   """Writes the JSON summary in json_dict out to file_path.
 
diff --git a/gm/rebaseline_server/column.py b/gm/rebaseline_server/column.py
index 7bce15a..d8d119d 100644
--- a/gm/rebaseline_server/column.py
+++ b/gm/rebaseline_server/column.py
@@ -10,6 +10,7 @@
 """
 
 # Keys used within dictionary representation of each column header.
+# NOTE: Keep these in sync with static/constants.js
 KEY__HEADER_TEXT = 'headerText'
 KEY__HEADER_URL = 'headerUrl'
 KEY__IS_FILTERABLE = 'isFilterable'
diff --git a/gm/rebaseline_server/imagediffdb.py b/gm/rebaseline_server/imagediffdb.py
index 8cec46b..f3347f7 100644
--- a/gm/rebaseline_server/imagediffdb.py
+++ b/gm/rebaseline_server/imagediffdb.py
@@ -43,6 +43,14 @@
 
 VALUES_PER_BAND = 256
 
+# Keys used within DiffRecord dictionary representations.
+# NOTE: Keep these in sync with static/constants.js
+KEY__DIFFERENCE_DATA__MAX_DIFF_PER_CHANNEL = 'maxDiffPerChannel'
+KEY__DIFFERENCE_DATA__NUM_DIFF_PIXELS = 'numDifferingPixels'
+KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS = 'percentDifferingPixels'
+KEY__DIFFERENCE_DATA__PERCEPTUAL_DIFF = 'perceptualDifference'
+KEY__DIFFERENCE_DATA__WEIGHTED_DIFF = 'weightedDiffMeasure'
+
 
 class DiffRecord(object):
   """ Record of differences between two images. """
@@ -186,10 +194,12 @@
     """Returns a dictionary representation of this DiffRecord, as needed when
     constructing the JSON representation."""
     return {
-        'numDifferingPixels': self._num_pixels_differing,
-        'percentDifferingPixels': self.get_percent_pixels_differing(),
-        'weightedDiffMeasure': self.get_weighted_diff_measure(),
-        'maxDiffPerChannel': self._max_diff_per_channel,
+        KEY__DIFFERENCE_DATA__NUM_DIFF_PIXELS: self._num_pixels_differing,
+        KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS:
+            self.get_percent_pixels_differing(),
+        KEY__DIFFERENCE_DATA__WEIGHTED_DIFF: self.get_weighted_diff_measure(),
+        KEY__DIFFERENCE_DATA__MAX_DIFF_PER_CHANNEL: self._max_diff_per_channel,
+        KEY__DIFFERENCE_DATA__PERCEPTUAL_DIFF: self._perceptual_difference,
     }
 
 
@@ -286,6 +296,7 @@
     total_diff += histogram[index] * (index % VALUES_PER_BAND)**2
   return float(100 * total_diff) / max_diff
 
+
 def _max_per_band(histogram):
   """Given the histogram of an image, return the maximum value of each band
   (a.k.a. "color channel", such as R/G/B) across the entire image.
@@ -312,6 +323,7 @@
         break
   return max_per_band
 
+
 def _generate_image_diff(image1, image2):
   """Wrapper for ImageChops.difference(image1, image2) that will handle some
   errors automatically, or at least yield more useful error messages.
@@ -333,6 +345,7 @@
         repr(image1), repr(image2)))
     raise
 
+
 def _download_and_open_image(local_filepath, url):
   """Open the image at local_filepath; if there is no file at that path,
   download it from url to that path and then open it.
@@ -350,6 +363,7 @@
         shutil.copyfileobj(fsrc=url_handle, fdst=file_handle)
   return _open_image(local_filepath)
 
+
 def _open_image(filepath):
   """Wrapper for Image.open(filepath) that yields more useful error messages.
 
@@ -364,6 +378,7 @@
     logging.error('IOError loading image file %s' % filepath)
     raise
 
+
 def _save_image(image, filepath, format='PNG'):
   """Write an image to disk, creating any intermediate directories as needed.
 
@@ -376,6 +391,7 @@
   _mkdir_unless_exists(os.path.dirname(filepath))
   image.save(filepath, format)
 
+
 def _mkdir_unless_exists(path):
   """Unless path refers to an already-existing directory, create it.
 
@@ -385,6 +401,7 @@
   if not os.path.isdir(path):
     os.makedirs(path)
 
+
 def _sanitize_locator(locator):
   """Returns a sanitized version of a locator (one in which we know none of the
   characters will have special meaning in filenames).
@@ -394,10 +411,14 @@
   """
   return DISALLOWED_FILEPATH_CHAR_REGEX.sub('_', str(locator))
 
+
 def _get_difference_locator(expected_image_locator, actual_image_locator):
   """Returns the locator string used to look up the diffs between expected_image
   and actual_image.
 
+  We must keep this function in sync with getImageDiffRelativeUrl() in
+  static/loader.js
+
   Args:
     expected_image_locator: locator string pointing at expected image
     actual_image_locator: locator string pointing at actual image
diff --git a/gm/rebaseline_server/imagepair.py b/gm/rebaseline_server/imagepair.py
index bba36fa..f02704e 100644
--- a/gm/rebaseline_server/imagepair.py
+++ b/gm/rebaseline_server/imagepair.py
@@ -12,6 +12,7 @@
 import posixpath
 
 # Keys used within ImagePair dictionary representations.
+# NOTE: Keep these in sync with static/constants.js
 KEY__DIFFERENCE_DATA = 'differenceData'
 KEY__EXPECTATIONS_DATA = 'expectations'
 KEY__EXTRA_COLUMN_VALUES = 'extraColumns'
@@ -31,8 +32,10 @@
     Args:
       image_diff_db: ImageDiffDB instance we use to generate/store image diffs
       base_url: base of all image URLs
-      imageA_relative_url: URL pointing at an image, relative to base_url
-      imageB_relative_url: URL pointing at an image, relative to base_url
+      imageA_relative_url: string; URL pointing at an image, relative to
+          base_url; or None, if this image is missing
+      imageB_relative_url: string; URL pointing at an image, relative to
+          base_url; or None, if this image is missing
       expectations: optional dictionary containing expectations-specific
           metadata (ignore-failure, bug numbers, etc.)
       extra_columns: optional dictionary containing more metadata (test name,
@@ -43,7 +46,9 @@
     self.imageB_relative_url = imageB_relative_url
     self.expectations_dict = expectations
     self.extra_columns_dict = extra_columns
-    if imageA_relative_url == imageB_relative_url:
+    if not imageA_relative_url or not imageB_relative_url:
+      self.diff_record = None
+    elif imageA_relative_url == imageB_relative_url:
       self.diff_record = None
     else:
       # TODO(epoger): Rather than blocking until image_diff_db can read in
diff --git a/gm/rebaseline_server/imagepair_test.py b/gm/rebaseline_server/imagepair_test.py
index d29438e..b2cae31 100755
--- a/gm/rebaseline_server/imagepair_test.py
+++ b/gm/rebaseline_server/imagepair_test.py
@@ -87,6 +87,7 @@
                     'maxDiffPerChannel': [255, 255, 247],
                     'numDifferingPixels': 662,
                     'percentDifferingPixels': 0.0662,
+                    'perceptualDifference': 0.06620000000000914,
                     'weightedDiffMeasure': 0.01127756555171088,
                 },
                 'imageAUrl': 'arcofzorro/16206093933823793653.png',
@@ -113,6 +114,7 @@
                     'maxDiffPerChannel': [255, 0, 255],
                     'numDifferingPixels': 102400,
                     'percentDifferingPixels': 100.00,
+                    'perceptualDifference': 100.00,
                     'weightedDiffMeasure': 66.66666666666667,
                 },
                 'expectations': {
diff --git a/gm/rebaseline_server/imagepairset.py b/gm/rebaseline_server/imagepairset.py
index 2e173f5..26c833e 100644
--- a/gm/rebaseline_server/imagepairset.py
+++ b/gm/rebaseline_server/imagepairset.py
@@ -12,7 +12,8 @@
 import column
 
 # Keys used within dictionary representation of ImagePairSet.
-KEY__COLUMNHEADERS = 'columnHeaders'
+# NOTE: Keep these in sync with static/constants.js
+KEY__EXTRACOLUMNHEADERS = 'extraColumnHeaders'
 KEY__IMAGEPAIRS = 'imagePairs'
 KEY__IMAGESETS = 'imageSets'
 KEY__IMAGESETS__BASE_URL = 'baseUrl'
@@ -55,7 +56,7 @@
     extra_columns_dict = image_pair.extra_columns_dict
     if extra_columns_dict:
       for column_id, value in extra_columns_dict.iteritems():
-        self._add_extra_column_entry(column_id, value)
+        self._add_extra_column_value_to_summary(column_id, value)
 
   def set_column_header_factory(self, column_id, column_header_factory):
     """Overrides the default settings for one of the extraColumn headers.
@@ -80,19 +81,36 @@
       self._column_header_factories[column_id] = column_header_factory
     return column_header_factory
 
-  def _add_extra_column_entry(self, column_id, value):
+  def ensure_extra_column_values_in_summary(self, column_id, values):
+    """Ensure this column_id/value pair is part of the extraColumns summary.
+
+    Args:
+      column_id: string; unique ID of this column
+      value: string; a possible value for this column
+    """
+    for value in values:
+      self._add_extra_column_value_to_summary(
+          column_id=column_id, value=value, addend=0)
+
+  def _add_extra_column_value_to_summary(self, column_id, value, addend=1):
     """Records one column_id/value extraColumns pair found within an ImagePair.
 
     We use this information to generate tallies within the column header
     (how many instances we saw of a particular value, within a particular
     extraColumn).
+
+    Args:
+      column_id: string; unique ID of this column (must match a key within
+          an ImagePair's extra_columns dictionary)
+      value: string; a possible value for this column
+      addend: integer; how many instances to add to the tally
     """
     known_values_for_column = self._extra_column_tallies.get(column_id, None)
     if not known_values_for_column:
       known_values_for_column = {}
       self._extra_column_tallies[column_id] = known_values_for_column
     instances_of_this_value = known_values_for_column.get(value, 0)
-    instances_of_this_value += 1
+    instances_of_this_value += addend
     known_values_for_column[value] = instances_of_this_value
 
   def _column_headers_as_dict(self):
@@ -110,7 +128,7 @@
     Uses the KEY__* constants as keys.
     """
     return {
-        KEY__COLUMNHEADERS: self._column_headers_as_dict(),
+        KEY__EXTRACOLUMNHEADERS: self._column_headers_as_dict(),
         KEY__IMAGEPAIRS: self._image_pair_dicts,
         KEY__IMAGESETS: [{
             KEY__IMAGESETS__BASE_URL: self._base_url,
diff --git a/gm/rebaseline_server/imagepairset_test.py b/gm/rebaseline_server/imagepairset_test.py
index 8f1edfc..815fd74 100755
--- a/gm/rebaseline_server/imagepairset_test.py
+++ b/gm/rebaseline_server/imagepairset_test.py
@@ -85,7 +85,7 @@
         MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_3_AS_DICT),
     ]
     expected_imageset_dict = {
-        'columnHeaders': {
+        'extraColumnHeaders': {
             'builder': {
                 'headerText': 'builder',
                 'isFilterable': True,
diff --git a/gm/rebaseline_server/results.py b/gm/rebaseline_server/results.py
index 3b57bc1..c7915f2 100755
--- a/gm/rebaseline_server/results.py
+++ b/gm/rebaseline_server/results.py
@@ -32,27 +32,43 @@
   sys.path.append(GM_DIRECTORY)
 import gm_json
 import imagediffdb
+import imagepair
+import imagepairset
+
+# Keys used to link an image to a particular GM test.
+# NOTE: Keep these in sync with static/constants.js
+KEY__EXPECTATIONS__BUGS = gm_json.JSONKEY_EXPECTEDRESULTS_BUGS
+KEY__EXPECTATIONS__IGNOREFAILURE = gm_json.JSONKEY_EXPECTEDRESULTS_IGNOREFAILURE
+KEY__EXPECTATIONS__REVIEWED = gm_json.JSONKEY_EXPECTEDRESULTS_REVIEWED
+KEY__EXTRACOLUMN__BUILDER = 'builder'
+KEY__EXTRACOLUMN__CONFIG = 'config'
+KEY__EXTRACOLUMN__RESULT_TYPE = 'resultType'
+KEY__EXTRACOLUMN__TEST = 'test'
+KEY__HEADER__RESULTS_ALL = 'all'
+KEY__HEADER__RESULTS_FAILURES = 'failures'
+KEY__NEW_IMAGE_URL = 'newImageUrl'
+KEY__RESULT_TYPE__FAILED = gm_json.JSONKEY_ACTUALRESULTS_FAILED
+KEY__RESULT_TYPE__FAILUREIGNORED = gm_json.JSONKEY_ACTUALRESULTS_FAILUREIGNORED
+KEY__RESULT_TYPE__NOCOMPARISON = gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON
+KEY__RESULT_TYPE__SUCCEEDED = gm_json.JSONKEY_ACTUALRESULTS_SUCCEEDED
+
+EXPECTATION_FIELDS_PASSED_THRU_VERBATIM = [
+    KEY__EXPECTATIONS__BUGS,
+    KEY__EXPECTATIONS__IGNOREFAILURE,
+    KEY__EXPECTATIONS__REVIEWED,
+]
 
 IMAGE_FILENAME_RE = re.compile(gm_json.IMAGE_FILENAME_PATTERN)
 IMAGE_FILENAME_FORMATTER = '%s_%s.png'  # pass in (testname, config)
 
-FIELDS_PASSED_THRU_VERBATIM = [
-    gm_json.JSONKEY_EXPECTEDRESULTS_BUGS,
-    gm_json.JSONKEY_EXPECTEDRESULTS_IGNOREFAILURE,
-    gm_json.JSONKEY_EXPECTEDRESULTS_REVIEWED,
-]
-CATEGORIES_TO_SUMMARIZE = [
-    'builder', 'test', 'config', 'resultType',
-    gm_json.JSONKEY_EXPECTEDRESULTS_IGNOREFAILURE,
-    gm_json.JSONKEY_EXPECTEDRESULTS_REVIEWED,
-]
+IMAGEPAIR_SET_DESCRIPTIONS = ('expected image', 'actual image')
 
-RESULTS_ALL = 'all'
-RESULTS_FAILURES = 'failures'
 
 class Results(object):
-  """ Loads actual and expected results from all builders, supplying combined
-  reports as requested.
+  """ Loads actual and expected GM results into an ImagePairSet.
+
+  Loads actual and expected results from all builders, except for those skipped
+  by _ignore_builder().
 
   Once this object has been constructed, the results (in self._results[])
   are immutable.  If you want to update the results based on updated JSON
@@ -94,14 +110,17 @@
 
          [
            {
-             'builder': 'Test-Mac10.6-MacMini4.1-GeForce320M-x86-Debug',
-             'test': 'bigmatrix',
-             'config': '8888',
-             'expectedHashType': 'bitmap-64bitMD5',
-             'expectedHashDigest': '10894408024079689926',
-             'bugs': [123, 456],
-             'ignore-failure': false,
-             'reviewed-by-human': true,
+             imagepair.KEY__EXPECTATIONS_DATA: {
+               KEY__EXPECTATIONS__BUGS: [123, 456],
+               KEY__EXPECTATIONS__IGNOREFAILURE: false,
+               KEY__EXPECTATIONS__REVIEWED: true,
+             },
+             imagepair.KEY__EXTRA_COLUMN_VALUES: {
+               KEY__EXTRACOLUMN__BUILDER: 'Test-Mac10.6-MacMini4.1-GeForce320M-x86-Debug',
+               KEY__EXTRACOLUMN__CONFIG: '8888',
+               KEY__EXTRACOLUMN__TEST: 'bigmatrix',
+             },
+             KEY__NEW_IMAGE_URL: 'bitmap-64bitMD5/bigmatrix/10894408024079689926.png',
            },
            ...
          ]
@@ -109,18 +128,21 @@
     """
     expected_builder_dicts = Results._read_dicts_from_root(self._expected_root)
     for mod in modifications:
-      image_name = IMAGE_FILENAME_FORMATTER % (mod['test'], mod['config'])
-      # TODO(epoger): assumes a single allowed digest per test
-      allowed_digests = [[mod['expectedHashType'],
-                          int(mod['expectedHashDigest'])]]
+      image_name = IMAGE_FILENAME_FORMATTER % (
+          mod[imagepair.KEY__EXTRA_COLUMN_VALUES][KEY__EXTRACOLUMN__TEST],
+          mod[imagepair.KEY__EXTRA_COLUMN_VALUES][KEY__EXTRACOLUMN__CONFIG])
+      _, hash_type, hash_digest = gm_json.SplitGmRelativeUrl(
+          mod[KEY__NEW_IMAGE_URL])
+      allowed_digests = [[hash_type, int(hash_digest)]]
       new_expectations = {
           gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS: allowed_digests,
       }
-      for field in FIELDS_PASSED_THRU_VERBATIM:
-        value = mod.get(field)
+      for field in EXPECTATION_FIELDS_PASSED_THRU_VERBATIM:
+        value = mod[imagepair.KEY__EXPECTATIONS_DATA].get(field)
         if value is not None:
           new_expectations[field] = value
-      builder_dict = expected_builder_dicts[mod['builder']]
+      builder_dict = expected_builder_dicts[
+          mod[imagepair.KEY__EXTRA_COLUMN_VALUES][KEY__EXTRACOLUMN__BUILDER]]
       builder_expectations = builder_dict.get(gm_json.JSONKEY_EXPECTEDRESULTS)
       if not builder_expectations:
         builder_expectations = {}
@@ -135,47 +157,7 @@
       type: string describing which types of results to include; must be one
             of the RESULTS_* constants
 
-    Results are returned as a dictionary in this form:
-
-       {
-         'categories': # dictionary of categories listed in
-                       # CATEGORIES_TO_SUMMARIZE, with the number of times
-                       # each value appears within its category
-         {
-           'resultType': # category name
-           {
-             'failed': 29, # category value and total number found of that value
-             'failure-ignored': 948,
-             'no-comparison': 4502,
-             'succeeded': 38609,
-           },
-           'builder':
-           {
-             'Test-Mac10.6-MacMini4.1-GeForce320M-x86-Debug': 1286,
-             'Test-Mac10.6-MacMini4.1-GeForce320M-x86-Release': 1134,
-             ...
-           },
-           ... # other categories from CATEGORIES_TO_SUMMARIZE
-         }, # end of 'categories' dictionary
-
-         'testData': # list of test results, with a dictionary for each
-         [
-           {
-             'resultType': 'failed',
-             'builder': 'Test-Mac10.6-MacMini4.1-GeForce320M-x86-Debug',
-             'test': 'bigmatrix',
-             'config': '8888',
-             'expectedHashType': 'bitmap-64bitMD5',
-             'expectedHashDigest': '10894408024079689926',
-             'actualHashType': 'bitmap-64bitMD5',
-             'actualHashDigest': '2409857384569',
-             'bugs': [123, 456],
-             'ignore-failure': false,
-             'reviewed-by-human': true,
-           },
-           ...
-         ], # end of 'testData' list
-       }
+    Results are returned in a dictionary as output by ImagePairSet.as_dict().
     """
     return self._results[type]
 
@@ -227,6 +209,24 @@
     return meta_dict
 
   @staticmethod
+  def _create_relative_url(hashtype_and_digest, test_name):
+    """Returns the URL for this image, relative to GM_ACTUALS_ROOT_HTTP_URL.
+
+    If we don't have a record of this image, returns None.
+
+    Args:
+      hashtype_and_digest: (hash_type, hash_digest) tuple, or None if we
+          don't have a record of this image
+      test_name: string; name of the GM test that created this image
+    """
+    if not hashtype_and_digest:
+      return None
+    return gm_json.CreateGmRelativeUrl(
+        test_name=test_name,
+        hash_type=hashtype_and_digest[0],
+        hash_digest=hashtype_and_digest[1])
+
+  @staticmethod
   def _write_dicts_to_root(meta_dict, root, pattern='*.json'):
     """Write all per-builder dictionaries within meta_dict to files under
     the root path.
@@ -272,36 +272,6 @@
           'for builders %s' % (
               expected_builders_written, actual_builders_written))
 
-  def _generate_pixel_diffs_if_needed(self, test, expected_image, actual_image):
-    """If expected_image and actual_image both exist but are different,
-    add the image pair to self._image_diff_db and generate pixel diffs.
-
-    Args:
-      test: string; name of test
-      expected_image: (hashType, hashDigest) tuple describing the expected image
-      actual_image: (hashType, hashDigest) tuple describing the actual image
-    """
-    if expected_image == actual_image:
-      return
-
-    (expected_hashtype, expected_hashdigest) = expected_image
-    (actual_hashtype, actual_hashdigest) = actual_image
-    if None in [expected_hashtype, expected_hashdigest,
-                actual_hashtype, actual_hashdigest]:
-      return
-
-    expected_url = gm_json.CreateGmActualUrl(
-        test_name=test, hash_type=expected_hashtype,
-        hash_digest=expected_hashdigest)
-    actual_url = gm_json.CreateGmActualUrl(
-        test_name=test, hash_type=actual_hashtype,
-        hash_digest=actual_hashdigest)
-    self._image_diff_db.add_image_pair(
-        expected_image_locator=expected_hashdigest,
-        expected_image_url=expected_url,
-        actual_image_locator=actual_hashdigest,
-        actual_image_url=actual_url)
-
   def _load_actual_and_expected(self):
     """Loads the results of all tests, across all builders (based on the
     files within self._actuals_root and self._expected_root),
@@ -314,25 +284,23 @@
                  self._expected_root)
     expected_builder_dicts = Results._read_dicts_from_root(self._expected_root)
 
-    categories_all = {}
-    categories_failures = {}
+    all_image_pairs = imagepairset.ImagePairSet(IMAGEPAIR_SET_DESCRIPTIONS)
+    failing_image_pairs = imagepairset.ImagePairSet(IMAGEPAIR_SET_DESCRIPTIONS)
 
-    Results._ensure_included_in_category_dict(categories_all,
-                                              'resultType', [
-        gm_json.JSONKEY_ACTUALRESULTS_FAILED,
-        gm_json.JSONKEY_ACTUALRESULTS_FAILUREIGNORED,
-        gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON,
-        gm_json.JSONKEY_ACTUALRESULTS_SUCCEEDED,
+    all_image_pairs.ensure_extra_column_values_in_summary(
+        column_id=KEY__EXTRACOLUMN__RESULT_TYPE, values=[
+            KEY__RESULT_TYPE__FAILED,
+            KEY__RESULT_TYPE__FAILUREIGNORED,
+            KEY__RESULT_TYPE__NOCOMPARISON,
+            KEY__RESULT_TYPE__SUCCEEDED,
         ])
-    Results._ensure_included_in_category_dict(categories_failures,
-                                              'resultType', [
-        gm_json.JSONKEY_ACTUALRESULTS_FAILED,
-        gm_json.JSONKEY_ACTUALRESULTS_FAILUREIGNORED,
-        gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON,
+    failing_image_pairs.ensure_extra_column_values_in_summary(
+        column_id=KEY__EXTRACOLUMN__RESULT_TYPE, values=[
+            KEY__RESULT_TYPE__FAILED,
+            KEY__RESULT_TYPE__FAILUREIGNORED,
+            KEY__RESULT_TYPE__NOCOMPARISON,
         ])
 
-    data_all = []
-    data_failures = []
     builders = sorted(actual_builder_dicts.keys())
     num_builders = len(builders)
     builder_num = 0
@@ -347,19 +315,30 @@
         if not results_of_this_type:
           continue
         for image_name in sorted(results_of_this_type.keys()):
-          actual_image = results_of_this_type[image_name]
+          (test, config) = IMAGE_FILENAME_RE.match(image_name).groups()
+          actual_image_relative_url = Results._create_relative_url(
+              hashtype_and_digest=results_of_this_type[image_name],
+              test_name=test)
 
           # Default empty expectations; overwrite these if we find any real ones
           expectations_per_test = None
-          expected_image = [None, None]
+          expected_image_relative_url = None
+          expectations_dict = None
           try:
             expectations_per_test = (
                 expected_builder_dicts
                 [builder][gm_json.JSONKEY_EXPECTEDRESULTS][image_name])
-            # TODO(epoger): assumes a single allowed digest per test
-            expected_image = (
+            # TODO(epoger): assumes a single allowed digest per test, which is
+            # fine; see https://code.google.com/p/skia/issues/detail?id=1787
+            expected_image_hashtype_and_digest = (
                 expectations_per_test
                 [gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS][0])
+            expected_image_relative_url = Results._create_relative_url(
+                hashtype_and_digest=expected_image_hashtype_and_digest,
+                test_name=test)
+            expectations_dict = {}
+            for field in EXPECTATION_FIELDS_PASSED_THRU_VERBATIM:
+              expectations_dict[field] = expectations_per_test.get(field)
           except (KeyError, TypeError):
             # There are several cases in which we would expect to find
             # no expectations for a given test:
@@ -380,11 +359,11 @@
             # Log other types, because they are rare and we should know about
             # them, but don't throw an exception, because we need to keep our
             # tools working in the meanwhile!
-            if result_type != gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON:
+            if result_type != KEY__RESULT_TYPE__NOCOMPARISON:
               logging.warning('No expectations found for test: %s' % {
-                  'builder': builder,
+                  KEY__EXTRACOLUMN__BUILDER: builder,
+                  KEY__EXTRACOLUMN__RESULT_TYPE: result_type,
                   'image_name': image_name,
-                  'result_type': result_type,
                   })
 
           # If this test was recently rebaselined, it will remain in
@@ -400,122 +379,32 @@
           # categories recorded within the gm_actuals AT ALL, and
           # instead evaluate the result_type ourselves based on what
           # we see in expectations vs actual checksum?
-          if expected_image == actual_image:
-            updated_result_type = gm_json.JSONKEY_ACTUALRESULTS_SUCCEEDED
+          if expected_image_relative_url == actual_image_relative_url:
+            updated_result_type = KEY__RESULT_TYPE__SUCCEEDED
           else:
             updated_result_type = result_type
-
-          (test, config) = IMAGE_FILENAME_RE.match(image_name).groups()
-          self._generate_pixel_diffs_if_needed(
-              test=test, expected_image=expected_image,
-              actual_image=actual_image)
-          results_for_this_test = {
-              'resultType': updated_result_type,
-              'builder': builder,
-              'test': test,
-              'config': config,
-              'actualHashType': actual_image[0],
-              'actualHashDigest': str(actual_image[1]),
-              'expectedHashType': expected_image[0],
-              'expectedHashDigest': str(expected_image[1]),
-
-              # FIELDS_PASSED_THRU_VERBATIM that may be overwritten below...
-              gm_json.JSONKEY_EXPECTEDRESULTS_IGNOREFAILURE: False,
+          extra_columns_dict = {
+              KEY__EXTRACOLUMN__RESULT_TYPE: updated_result_type,
+              KEY__EXTRACOLUMN__BUILDER: builder,
+              KEY__EXTRACOLUMN__TEST: test,
+              KEY__EXTRACOLUMN__CONFIG: config,
           }
-          if expectations_per_test:
-            for field in FIELDS_PASSED_THRU_VERBATIM:
-              results_for_this_test[field] = expectations_per_test.get(field)
-
-          if updated_result_type == gm_json.JSONKEY_ACTUALRESULTS_NOCOMPARISON:
-            pass # no diff record to calculate at all
-          elif updated_result_type == gm_json.JSONKEY_ACTUALRESULTS_SUCCEEDED:
-            results_for_this_test['numDifferingPixels'] = 0
-            results_for_this_test['percentDifferingPixels'] = 0
-            results_for_this_test['weightedDiffMeasure'] = 0
-            results_for_this_test['perceptualDifference'] = 0
-            results_for_this_test['maxDiffPerChannel'] = 0
-          else:
-            try:
-              diff_record = self._image_diff_db.get_diff_record(
-                  expected_image_locator=expected_image[1],
-                  actual_image_locator=actual_image[1])
-              results_for_this_test['numDifferingPixels'] = (
-                  diff_record.get_num_pixels_differing())
-              results_for_this_test['percentDifferingPixels'] = (
-                  diff_record.get_percent_pixels_differing())
-              results_for_this_test['weightedDiffMeasure'] = (
-                  diff_record.get_weighted_diff_measure())
-              results_for_this_test['perceptualDifference'] = (
-                  diff_record.get_perceptual_difference())
-              results_for_this_test['maxDiffPerChannel'] = (
-                  diff_record.get_max_diff_per_channel())
-            except KeyError:
-              logging.warning('unable to find diff_record for ("%s", "%s")' %
-                              (expected_image[1], actual_image[1]))
-              pass
-
-          Results._add_to_category_dict(categories_all, results_for_this_test)
-          data_all.append(results_for_this_test)
-
-          # TODO(epoger): In effect, we have a list of resultTypes that we
-          # include in the different result lists (data_all and data_failures).
-          # This same list should be used by the calls to
-          # Results._ensure_included_in_category_dict() earlier on.
-          if updated_result_type != gm_json.JSONKEY_ACTUALRESULTS_SUCCEEDED:
-            Results._add_to_category_dict(categories_failures,
-                                          results_for_this_test)
-            data_failures.append(results_for_this_test)
+          image_pair = imagepair.ImagePair(
+              image_diff_db=self._image_diff_db,
+              base_url=gm_json.GM_ACTUALS_ROOT_HTTP_URL,
+              imageA_relative_url=expected_image_relative_url,
+              imageB_relative_url=actual_image_relative_url,
+              expectations=expectations_dict,
+              extra_columns=extra_columns_dict)
+          all_image_pairs.add_image_pair(image_pair)
+          if updated_result_type != KEY__RESULT_TYPE__SUCCEEDED:
+            failing_image_pairs.add_image_pair(image_pair)
 
     self._results = {
-      RESULTS_ALL:
-        {'categories': categories_all, 'testData': data_all},
-      RESULTS_FAILURES:
-        {'categories': categories_failures, 'testData': data_failures},
+      KEY__HEADER__RESULTS_ALL: all_image_pairs.as_dict(),
+      KEY__HEADER__RESULTS_FAILURES: failing_image_pairs.as_dict(),
     }
 
-  @staticmethod
-  def _add_to_category_dict(category_dict, test_results):
-    """Add test_results to the category dictionary we are building.
-    (See documentation of self.get_results_of_type() for the format of this
-    dictionary.)
-
-    Args:
-      category_dict: category dict-of-dicts to add to; modify this in-place
-      test_results: test data with which to update category_list, in a dict:
-         {
-           'category_name': 'category_value',
-           'category_name': 'category_value',
-           ...
-         }
-    """
-    for category in CATEGORIES_TO_SUMMARIZE:
-      category_value = test_results.get(category)
-      if not category_dict.get(category):
-        category_dict[category] = {}
-      if not category_dict[category].get(category_value):
-        category_dict[category][category_value] = 0
-      category_dict[category][category_value] += 1
-
-  @staticmethod
-  def _ensure_included_in_category_dict(category_dict,
-                                        category_name, category_values):
-    """Ensure that the category name/value pairs are included in category_dict,
-    even if there aren't any results with that name/value pair.
-    (See documentation of self.get_results_of_type() for the format of this
-    dictionary.)
-
-    Args:
-      category_dict: category dict-of-dicts to modify
-      category_name: category name, as a string
-      category_values: list of values we want to make sure are represented
-                       for this category
-    """
-    if not category_dict.get(category_name):
-      category_dict[category_name] = {}
-    for category_value in category_values:
-      if not category_dict[category_name].get(category_value):
-        category_dict[category_name][category_value] = 0
-
 
 def main():
   logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
@@ -538,7 +427,8 @@
   results = Results(actuals_root=args.actuals,
                     expected_root=args.expectations,
                     generated_images_root=args.workdir)
-  gm_json.WriteToFile(results.get_results_of_type(RESULTS_ALL), args.outfile)
+  gm_json.WriteToFile(results.get_results_of_type(KEY__HEADER__RESULTS_ALL),
+                      args.outfile)
 
 
 if __name__ == '__main__':
diff --git a/gm/rebaseline_server/results_test.py b/gm/rebaseline_server/results_test.py
index 482fd9f..958222c 100755
--- a/gm/rebaseline_server/results_test.py
+++ b/gm/rebaseline_server/results_test.py
@@ -35,8 +35,9 @@
         actuals_root=os.path.join(self._input_dir, 'gm-actuals'),
         expected_root=os.path.join(self._input_dir, 'gm-expectations'),
         generated_images_root=self._temp_dir)
-    gm_json.WriteToFile(results_obj.get_results_of_type(results.RESULTS_ALL),
-                        os.path.join(self._output_dir_actual, 'gm.json'))
+    gm_json.WriteToFile(
+        results_obj.get_results_of_type(results.KEY__HEADER__RESULTS_ALL),
+        os.path.join(self._output_dir_actual, 'gm.json'))
 
 
 def main():
diff --git a/gm/rebaseline_server/server.py b/gm/rebaseline_server/server.py
index 0906c4e..bbce2d0 100755
--- a/gm/rebaseline_server/server.py
+++ b/gm/rebaseline_server/server.py
@@ -40,6 +40,7 @@
 import svn
 
 # Imports from local dir
+import imagepairset
 import results
 
 ACTUALS_SVN_REPO = 'http://skia-autogen.googlecode.com/svn/gm-actual'
@@ -59,6 +60,20 @@
                  'json': 'application/json'
                  }
 
+# Keys that server.py uses to create the toplevel content header.
+# NOTE: Keep these in sync with static/constants.js
+KEY__EDITS__MODIFICATIONS = 'modifications'
+KEY__EDITS__OLD_RESULTS_HASH = 'oldResultsHash'
+KEY__EDITS__OLD_RESULTS_TYPE = 'oldResultsType'
+KEY__HEADER = 'header'
+KEY__HEADER__DATAHASH = 'dataHash'
+KEY__HEADER__IS_EDITABLE = 'isEditable'
+KEY__HEADER__IS_EXPORTED = 'isExported'
+KEY__HEADER__IS_STILL_LOADING = 'resultsStillLoading'
+KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE = 'timeNextUpdateAvailable'
+KEY__HEADER__TIME_UPDATED = 'timeUpdated'
+KEY__HEADER__TYPE = 'type'
+
 DEFAULT_ACTUALS_DIR = '.gm-actuals'
 DEFAULT_PORT = 8888
 
@@ -313,10 +328,11 @@
     else:
       now = int(time.time())
       response_dict = {
-          'header': {
-              'resultsStillLoading': True,
-              'timeUpdated': now,
-              'timeNextUpdateAvailable': now + RELOAD_INTERVAL_UNTIL_READY,
+          KEY__HEADER: {
+              KEY__HEADER__IS_STILL_LOADING: True,
+              KEY__HEADER__TIME_UPDATED: now,
+              KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE:
+                  now + RELOAD_INTERVAL_UNTIL_READY,
           },
       }
     self.send_json_dict(response_dict)
@@ -332,7 +348,7 @@
     """
     response_dict = results_obj.get_results_of_type(type)
     time_updated = results_obj.get_timestamp()
-    response_dict['header'] = {
+    response_dict[KEY__HEADER] = {
         # Timestamps:
         # 1. when this data was last updated
         # 2. when the caller should check back for new data (if ever)
@@ -340,23 +356,25 @@
         # We only return these timestamps if the --reload argument was passed;
         # otherwise, we have no idea when the expectations were last updated
         # (we allow the user to maintain her own expectations as she sees fit).
-        'timeUpdated': time_updated if _SERVER.reload_seconds else None,
-        'timeNextUpdateAvailable': (
+        KEY__HEADER__TIME_UPDATED:
+            time_updated if _SERVER.reload_seconds else None,
+        KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE:
             (time_updated+_SERVER.reload_seconds) if _SERVER.reload_seconds
-            else None),
+            else None,
 
         # The type we passed to get_results_of_type()
-        'type': type,
+        KEY__HEADER__TYPE: type,
 
-        # Hash of testData, which the client must return with any edits--
+        # Hash of dataset, which the client must return with any edits--
         # this ensures that the edits were made to a particular dataset.
-        'dataHash': str(hash(repr(response_dict['testData']))),
+        KEY__HEADER__DATAHASH: str(hash(repr(
+            response_dict[imagepairset.KEY__IMAGEPAIRS]))),
 
         # Whether the server will accept edits back.
-        'isEditable': _SERVER.is_editable,
+        KEY__HEADER__IS_EDITABLE: _SERVER.is_editable,
 
         # Whether the service is accessible from other hosts.
-        'isExported': _SERVER.is_exported,
+        KEY__HEADER__IS_EXPORTED: _SERVER.is_exported,
     }
     return response_dict
 
@@ -406,19 +424,15 @@
     format:
 
     {
-      'oldResultsType': 'all',    # type of results that the client loaded
-                                  # and then made modifications to
-      'oldResultsHash': 39850913, # hash of results when the client loaded them
-                                  # (ensures that the client and server apply
-                                  # modifications to the same base)
-      'modifications': [
-        {
-          'builder': 'Test-Android-Nexus10-MaliT604-Arm7-Debug',
-          'test': 'strokerect',
-          'config': 'gpu',
-          'expectedHashType': 'bitmap-64bitMD5',
-          'expectedHashDigest': '1707359671708613629',
-        },
+      KEY__EDITS__OLD_RESULTS_TYPE: 'all',  # type of results that the client
+                                            # loaded and then made
+                                            # modifications to
+      KEY__EDITS__OLD_RESULTS_HASH: 39850913, # hash of results when the client
+                                              # loaded them (ensures that the
+                                              # client and server apply
+                                              # modifications to the same base)
+      KEY__EDITS__MODIFICATIONS: [
+        # as needed by results.edit_expectations()
         ...
       ],
     }
@@ -445,15 +459,15 @@
     # no other thread updates expectations (from the Skia repo) while we are
     # updating them (using the info we received from the client).
     with _SERVER.results_rlock:
-      oldResultsType = data['oldResultsType']
+      oldResultsType = data[KEY__EDITS__OLD_RESULTS_TYPE]
       oldResults = _SERVER.results.get_results_of_type(oldResultsType)
-      oldResultsHash = str(hash(repr(oldResults['testData'])))
-      if oldResultsHash != data['oldResultsHash']:
+      oldResultsHash = str(hash(repr(oldResults[imagepairset.KEY__IMAGEPAIRS])))
+      if oldResultsHash != data[KEY__EDITS__OLD_RESULTS_HASH]:
         raise Exception('results of type "%s" changed while the client was '
                         'making modifications. The client should reload the '
                         'results and submit the modifications again.' %
                         oldResultsType)
-      _SERVER.results.edit_expectations(data['modifications'])
+      _SERVER.results.edit_expectations(data[KEY__EDITS__MODIFICATIONS])
 
     # Read the updated results back from disk.
     # We can do this in a separate thread; we should return our success message
diff --git a/gm/rebaseline_server/static/constants.js b/gm/rebaseline_server/static/constants.js
new file mode 100644
index 0000000..00c1852
--- /dev/null
+++ b/gm/rebaseline_server/static/constants.js
@@ -0,0 +1,69 @@
+/*
+ * Constants used by our imagediff-viewing Javascript code.
+ */
+var module = angular.module(
+    'ConstantsModule',
+    []
+);
+
+module.constant('constants', (function() {
+  return {
+    // NOTE: Keep these in sync with ../column.py
+    KEY__HEADER_TEXT: 'headerText',
+    KEY__HEADER_URL: 'headerUrl',
+    KEY__IS_FILTERABLE: 'isFilterable',
+    KEY__IS_SORTABLE: 'isSortable',
+    KEY__VALUES_AND_COUNTS: 'valuesAndCounts',
+
+    // NOTE: Keep these in sync with ../imagediffdb.py
+    KEY__DIFFERENCE_DATA__MAX_DIFF_PER_CHANNEL: 'maxDiffPerChannel',
+    KEY__DIFFERENCE_DATA__NUM_DIFF_PIXELS: 'numDifferingPixels',
+    KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS: 'percentDifferingPixels',
+    KEY__DIFFERENCE_DATA__PERCEPTUAL_DIFF: 'perceptualDifference',
+    KEY__DIFFERENCE_DATA__WEIGHTED_DIFF: 'weightedDiffMeasure',
+
+    // NOTE: Keep these in sync with ../imagepair.py
+    KEY__DIFFERENCE_DATA: 'differenceData',
+    KEY__EXPECTATIONS_DATA: 'expectations',
+    KEY__EXTRA_COLUMN_VALUES: 'extraColumns',
+    KEY__IMAGE_A_URL: 'imageAUrl',
+    KEY__IMAGE_B_URL: 'imageBUrl',
+    KEY__IS_DIFFERENT: 'isDifferent',
+
+    // NOTE: Keep these in sync with ../imagepairset.py
+    KEY__EXTRACOLUMNHEADERS: 'extraColumnHeaders',
+    KEY__IMAGEPAIRS: 'imagePairs',
+    KEY__IMAGESETS: 'imageSets',
+    KEY__IMAGESETS__BASE_URL: 'baseUrl',
+    KEY__IMAGESETS__DESCRIPTION: 'description',
+
+    // NOTE: Keep these in sync with ../results.py
+    KEY__EXPECTATIONS__BUGS: 'bugs',
+    KEY__EXPECTATIONS__IGNOREFAILURE: 'ignore-failure',
+    KEY__EXPECTATIONS__REVIEWED: 'reviewed-by-human',
+    KEY__EXTRACOLUMN__BUILDER: 'builder',
+    KEY__EXTRACOLUMN__CONFIG: 'config',
+    KEY__EXTRACOLUMN__RESULT_TYPE: 'resultType',
+    KEY__EXTRACOLUMN__TEST: 'test',
+    KEY__HEADER__RESULTS_ALL: 'all',
+    KEY__HEADER__RESULTS_FAILURES: 'failures',
+    KEY__NEW_IMAGE_URL: 'newImageUrl',
+    KEY__RESULT_TYPE__FAILED: 'failed',
+    KEY__RESULT_TYPE__FAILUREIGNORED: 'failure-ignored',
+    KEY__RESULT_TYPE__NOCOMPARISON: 'no-comparison',
+    KEY__RESULT_TYPE__SUCCEEDED: 'succeeded',
+
+    // NOTE: Keep these in sync with ../server.py
+    KEY__EDITS__MODIFICATIONS: 'modifications',
+    KEY__EDITS__OLD_RESULTS_HASH: 'oldResultsHash',
+    KEY__EDITS__OLD_RESULTS_TYPE: 'oldResultsType',
+    KEY__HEADER: 'header',
+    KEY__HEADER__DATAHASH: 'dataHash',
+    KEY__HEADER__IS_EDITABLE: 'isEditable',
+    KEY__HEADER__IS_EXPORTED: 'isExported',
+    KEY__HEADER__IS_STILL_LOADING: 'resultsStillLoading',
+    KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: 'timeNextUpdateAvailable',
+    KEY__HEADER__TIME_UPDATED: 'timeUpdated',
+    KEY__HEADER__TYPE: 'type',
+  }
+})())
diff --git a/gm/rebaseline_server/static/loader.js b/gm/rebaseline_server/static/loader.js
index 9ae84d6..ab79df8 100644
--- a/gm/rebaseline_server/static/loader.js
+++ b/gm/rebaseline_server/static/loader.js
@@ -1,38 +1,42 @@
 /*
  * Loader:
  * Reads GM result reports written out by results.py, and imports
- * them into $scope.categories and $scope.testData .
+ * them into $scope.extraColumnHeaders and $scope.imagePairs .
  */
 var Loader = angular.module(
     'Loader',
-    ['diff_viewer']
+    ['ConstantsModule', 'diff_viewer']
 );
 
-
 // TODO(epoger): Combine ALL of our filtering operations (including
 // truncation) into this one filter, so that runs most efficiently?
 // (We would have to make sure truncation still took place after
 // sorting, though.)
 Loader.filter(
-  'removeHiddenItems',
-  function() {
-    return function(unfilteredItems, hiddenResultTypes, hiddenConfigs,
+  'removeHiddenImagePairs',
+  function(constants) {
+    return function(unfilteredImagePairs, hiddenResultTypes, hiddenConfigs,
                     builderSubstring, testSubstring, viewingTab) {
-      var filteredItems = [];
-      for (var i = 0; i < unfilteredItems.length; i++) {
-        var item = unfilteredItems[i];
+      var filteredImagePairs = [];
+      for (var i = 0; i < unfilteredImagePairs.length; i++) {
+        var imagePair = unfilteredImagePairs[i];
+        var extraColumnValues = imagePair[constants.KEY__EXTRA_COLUMN_VALUES];
         // For performance, we examine the "set" objects directly rather
         // than calling $scope.isValueInSet().
         // Besides, I don't think we have access to $scope in here...
-        if (!(true == hiddenResultTypes[item.resultType]) &&
-            !(true == hiddenConfigs[item.config]) &&
-            !(-1 == item.builder.indexOf(builderSubstring)) &&
-            !(-1 == item.test.indexOf(testSubstring)) &&
-            (viewingTab == item.tab)) {
-          filteredItems.push(item);
+        if (!(true == hiddenResultTypes[extraColumnValues[
+                  constants.KEY__EXTRACOLUMN__RESULT_TYPE]]) &&
+            !(true == hiddenConfigs[extraColumnValues[
+                  constants.KEY__EXTRACOLUMN__CONFIG]]) &&
+            !(-1 == extraColumnValues[constants.KEY__EXTRACOLUMN__BUILDER]
+                    .indexOf(builderSubstring)) &&
+            !(-1 == extraColumnValues[constants.KEY__EXTRACOLUMN__TEST]
+                    .indexOf(testSubstring)) &&
+            (viewingTab == imagePair.tab)) {
+          filteredImagePairs.push(imagePair);
         }
       }
-      return filteredItems;
+      return filteredImagePairs;
     };
   }
 );
@@ -40,7 +44,8 @@
 
 Loader.controller(
   'Loader.Controller',
-    function($scope, $http, $filter, $location, $timeout) {
+    function($scope, $http, $filter, $location, $timeout, constants) {
+    $scope.constants = constants;
     $scope.windowTitle = "Loading GM Results...";
     $scope.resultsToLoad = $location.search().resultsToLoad;
     $scope.loadingMessage = "Loading results of type '" + $scope.resultsToLoad +
@@ -53,26 +58,33 @@
      */
     $http.get("/results/" + $scope.resultsToLoad).success(
       function(data, status, header, config) {
-        if (data.header.resultsStillLoading) {
+        var dataHeader = data[constants.KEY__HEADER];
+        if (dataHeader[constants.KEY__HEADER__IS_STILL_LOADING]) {
           $scope.loadingMessage =
               "Server is still loading results; will retry at " +
-              $scope.localTimeString(data.header.timeNextUpdateAvailable);
+              $scope.localTimeString(dataHeader[
+                  constants.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE]);
           $timeout(
               function(){location.reload();},
-              (data.header.timeNextUpdateAvailable * 1000) - new Date().getTime());
+              (dataHeader[constants.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE]
+               * 1000) - new Date().getTime());
         } else {
           $scope.loadingMessage = "Processing data, please wait...";
 
-          $scope.header = data.header;
-          $scope.categories = data.categories;
-          $scope.testData = data.testData;
-          $scope.sortColumn = 'weightedDiffMeasure';
+          $scope.header = dataHeader;
+          $scope.extraColumnHeaders = data[constants.KEY__EXTRACOLUMNHEADERS];
+          $scope.imagePairs = data[constants.KEY__IMAGEPAIRS];
+          $scope.imageSets = data[constants.KEY__IMAGESETS];
+          $scope.sortColumnSubdict = constants.KEY__DIFFERENCE_DATA;
+          $scope.sortColumnKey = constants.KEY__DIFFERENCE_DATA__WEIGHTED_DIFF;
           $scope.showTodos = false;
 
           $scope.showSubmitAdvancedSettings = false;
           $scope.submitAdvancedSettings = {};
-          $scope.submitAdvancedSettings['reviewed-by-human'] = true;
-          $scope.submitAdvancedSettings['ignore-failure'] = false;
+          $scope.submitAdvancedSettings[
+              constants.KEY__EXPECTATIONS__REVIEWED] = true;
+          $scope.submitAdvancedSettings[
+              constants.KEY__EXPECTATIONS__IGNOREFAILURE] = false;
           $scope.submitAdvancedSettings['bug'] = '';
 
           // Create the list of tabs (lists into which the user can file each
@@ -80,7 +92,7 @@
           $scope.tabs = [
             'Unfiled', 'Hidden'
           ];
-          if (data.header.isEditable) {
+          if (dataHeader[constants.KEY__HEADER__IS_EDITABLE]) {
             $scope.tabs = $scope.tabs.concat(
                 ['Pending Approval']);
           }
@@ -92,26 +104,32 @@
           for (var i = 0; i < $scope.tabs.length; i++) {
             $scope.numResultsPerTab[$scope.tabs[i]] = 0;
           }
-          $scope.numResultsPerTab[$scope.defaultTab] = $scope.testData.length;
+          $scope.numResultsPerTab[$scope.defaultTab] = $scope.imagePairs.length;
 
           // Add index and tab fields to all records.
-          for (var i = 0; i < $scope.testData.length; i++) {
-            $scope.testData[i].index = i;
-            $scope.testData[i].tab = $scope.defaultTab;
+          for (var i = 0; i < $scope.imagePairs.length; i++) {
+            $scope.imagePairs[i].index = i;
+            $scope.imagePairs[i].tab = $scope.defaultTab;
           }
 
           // Arrays within which the user can toggle individual elements.
-          $scope.selectedItems = [];
+          $scope.selectedImagePairs = [];
 
           // Sets within which the user can toggle individual elements.
-          $scope.hiddenResultTypes = {
-            'failure-ignored': true,
-            'no-comparison': true,
-            'succeeded': true,
-          };
-          $scope.allResultTypes = Object.keys(data.categories['resultType']);
+          $scope.hiddenResultTypes = {};
+          $scope.hiddenResultTypes[
+              constants.KEY__RESULT_TYPE__FAILUREIGNORED] = true;
+          $scope.hiddenResultTypes[
+              constants.KEY__RESULT_TYPE__NOCOMPARISON] = true;
+          $scope.hiddenResultTypes[
+              constants.KEY__RESULT_TYPE__SUCCEEDED] = true;
+          $scope.allResultTypes = Object.keys(
+              $scope.extraColumnHeaders[constants.KEY__EXTRACOLUMN__RESULT_TYPE]
+                                       [constants.KEY__VALUES_AND_COUNTS]);
           $scope.hiddenConfigs = {};
-          $scope.allConfigs = Object.keys(data.categories['config']);
+          $scope.allConfigs = Object.keys(
+              $scope.extraColumnHeaders[constants.KEY__EXTRACOLUMN__CONFIG]
+                                       [constants.KEY__VALUES_AND_COUNTS]);
 
           // Associative array of partial string matches per category.
           $scope.categoryValueMatch = {};
@@ -142,12 +160,12 @@
     /**
      * Select all currently showing tests.
      */
-    $scope.selectAllItems = function() {
-      var numItemsShowing = $scope.limitedTestData.length;
-      for (var i = 0; i < numItemsShowing; i++) {
-        var index = $scope.limitedTestData[i].index;
-        if (!$scope.isValueInArray(index, $scope.selectedItems)) {
-          $scope.toggleValueInArray(index, $scope.selectedItems);
+    $scope.selectAllImagePairs = function() {
+      var numImagePairsShowing = $scope.limitedImagePairs.length;
+      for (var i = 0; i < numImagePairsShowing; i++) {
+        var index = $scope.limitedImagePairs[i].index;
+        if (!$scope.isValueInArray(index, $scope.selectedImagePairs)) {
+          $scope.toggleValueInArray(index, $scope.selectedImagePairs);
         }
       }
     }
@@ -155,12 +173,12 @@
     /**
      * Deselect all currently showing tests.
      */
-    $scope.clearAllItems = function() {
-      var numItemsShowing = $scope.limitedTestData.length;
-      for (var i = 0; i < numItemsShowing; i++) {
-        var index = $scope.limitedTestData[i].index;
-        if ($scope.isValueInArray(index, $scope.selectedItems)) {
-          $scope.toggleValueInArray(index, $scope.selectedItems);
+    $scope.clearAllImagePairs = function() {
+      var numImagePairsShowing = $scope.limitedImagePairs.length;
+      for (var i = 0; i < numImagePairsShowing; i++) {
+        var index = $scope.limitedImagePairs[i].index;
+        if ($scope.isValueInArray(index, $scope.selectedImagePairs)) {
+          $scope.toggleValueInArray(index, $scope.selectedImagePairs);
         }
       }
     }
@@ -168,11 +186,11 @@
     /**
      * Toggle selection of all currently showing tests.
      */
-    $scope.toggleAllItems = function() {
-      var numItemsShowing = $scope.limitedTestData.length;
-      for (var i = 0; i < numItemsShowing; i++) {
-        var index = $scope.limitedTestData[i].index;
-        $scope.toggleValueInArray(index, $scope.selectedItems);
+    $scope.toggleAllImagePairs = function() {
+      var numImagePairsShowing = $scope.limitedImagePairs.length;
+      for (var i = 0; i < numImagePairsShowing; i++) {
+        var index = $scope.limitedImagePairs[i].index;
+        $scope.toggleValueInArray(index, $scope.selectedImagePairs);
       }
     }
 
@@ -192,33 +210,33 @@
     }
 
     /**
-     * Move the items in $scope.selectedItems to a different tab,
-     * and then clear $scope.selectedItems.
+     * Move the imagePairs in $scope.selectedImagePairs to a different tab,
+     * and then clear $scope.selectedImagePairs.
      *
      * @param newTab (string): name of the tab to move the tests to
      */
-    $scope.moveSelectedItemsToTab = function(newTab) {
-      $scope.moveItemsToTab($scope.selectedItems, newTab);
-      $scope.selectedItems = [];
+    $scope.moveSelectedImagePairsToTab = function(newTab) {
+      $scope.moveImagePairsToTab($scope.selectedImagePairs, newTab);
+      $scope.selectedImagePairs = [];
       $scope.updateResults();
     }
 
     /**
-     * Move a subset of $scope.testData to a different tab.
+     * Move a subset of $scope.imagePairs to a different tab.
      *
-     * @param itemIndices (array of ints): indices into $scope.testData
+     * @param imagePairIndices (array of ints): indices into $scope.imagePairs
      *        indicating which test results to move
      * @param newTab (string): name of the tab to move the tests to
      */
-    $scope.moveItemsToTab = function(itemIndices, newTab) {
-      var itemIndex;
-      var numItems = itemIndices.length;
-      for (var i = 0; i < numItems; i++) {
-        itemIndex = itemIndices[i];
-        $scope.numResultsPerTab[$scope.testData[itemIndex].tab]--;
-        $scope.testData[itemIndex].tab = newTab;
+    $scope.moveImagePairsToTab = function(imagePairIndices, newTab) {
+      var imagePairIndex;
+      var numImagePairs = imagePairIndices.length;
+      for (var i = 0; i < numImagePairs; i++) {
+        imagePairIndex = imagePairIndices[i];
+        $scope.numResultsPerTab[$scope.imagePairs[imagePairIndex].tab]--;
+        $scope.imagePairs[imagePairIndex].tab = newTab;
       }
-      $scope.numResultsPerTab[newTab] += numItems;
+      $scope.numResultsPerTab[newTab] += numImagePairs;
     }
 
 
@@ -277,14 +295,16 @@
       'resultsToLoad':       $scope.queryParameters.copiers.simple,
       'displayLimitPending': $scope.queryParameters.copiers.simple,
       'imageSizePending':    $scope.queryParameters.copiers.simple,
-      'sortColumn':          $scope.queryParameters.copiers.simple,
-
-      'builder': $scope.queryParameters.copiers.categoryValueMatch,
-      'test':    $scope.queryParameters.copiers.categoryValueMatch,
+      'sortColumnSubdict':   $scope.queryParameters.copiers.simple,
+      'sortColumnKey':       $scope.queryParameters.copiers.simple,
 
       'hiddenResultTypes': $scope.queryParameters.copiers.set,
       'hiddenConfigs':     $scope.queryParameters.copiers.set,
     };
+    $scope.queryParameters.map[constants.KEY__EXTRACOLUMN__BUILDER] =
+        $scope.queryParameters.copiers.categoryValueMatch;
+    $scope.queryParameters.map[constants.KEY__EXTRACOLUMN__TEST] =
+        $scope.queryParameters.copiers.categoryValueMatch;
 
     // Loads all parameters into $scope from the URL query string;
     // any which are not found within the URL will keep their current value.
@@ -339,7 +359,7 @@
       $scope.displayLimit = $scope.displayLimitPending;
       // TODO(epoger): Every time we apply a filter, AngularJS creates
       // another copy of the array.  Is there a way we can filter out
-      // the items as they are displayed, rather than storing multiple
+      // the imagePairs as they are displayed, rather than storing multiple
       // array copies?  (For better performance.)
 
       if ($scope.viewingTab == $scope.defaultTab) {
@@ -347,32 +367,34 @@
         // TODO(epoger): Until we allow the user to reverse sort order,
         // there are certain columns we want to sort in a different order.
         var doReverse = (
-            ($scope.sortColumn == 'percentDifferingPixels') ||
-            ($scope.sortColumn == 'weightedDiffMeasure'));
+            ($scope.sortColumnKey ==
+             constants.KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS) ||
+            ($scope.sortColumnKey ==
+             constants.KEY__DIFFERENCE_DATA__WEIGHTED_DIFF));
 
-        $scope.filteredTestData =
+        $scope.filteredImagePairs =
             $filter("orderBy")(
-                $filter("removeHiddenItems")(
-                    $scope.testData,
+                $filter("removeHiddenImagePairs")(
+                    $scope.imagePairs,
                     $scope.hiddenResultTypes,
                     $scope.hiddenConfigs,
                     $scope.categoryValueMatch.builder,
                     $scope.categoryValueMatch.test,
                     $scope.viewingTab
                 ),
-                $scope.sortColumn, doReverse);
-        $scope.limitedTestData = $filter("limitTo")(
-            $scope.filteredTestData, $scope.displayLimit);
+                $scope.getSortColumnValue, doReverse);
+        $scope.limitedImagePairs = $filter("limitTo")(
+            $scope.filteredImagePairs, $scope.displayLimit);
       } else {
-        $scope.filteredTestData =
+        $scope.filteredImagePairs =
             $filter("orderBy")(
                 $filter("filter")(
-                    $scope.testData,
+                    $scope.imagePairs,
                     {tab: $scope.viewingTab},
                     true
                 ),
-                $scope.sortColumn);
-        $scope.limitedTestData = $scope.filteredTestData;
+                $scope.getSortColumnValue);
+        $scope.limitedImagePairs = $scope.filteredImagePairs;
       }
       $scope.imageSize = $scope.imageSizePending;
       $scope.setUpdatesPending(false);
@@ -382,14 +404,33 @@
     /**
      * Re-sort the displayed results.
      *
-     * @param sortColumn (string): name of the column to sort on
+     * @param subdict (string): which subdictionary
+     *     (constants.KEY__DIFFERENCE_DATA, constants.KEY__EXPECTATIONS_DATA,
+     *      constants.KEY__EXTRA_COLUMN_VALUES) the sort column key is within
+     * @param key (string): sort by value associated with this key in subdict
      */
-    $scope.sortResultsBy = function(sortColumn) {
-      $scope.sortColumn = sortColumn;
+    $scope.sortResultsBy = function(subdict, key) {
+      $scope.sortColumnSubdict = subdict;
+      $scope.sortColumnKey = key;
       $scope.updateResults();
     }
 
     /**
+     * For a particular ImagePair, return the value of the column we are
+     * sorting on (according to $scope.sortColumnSubdict and
+     * $scope.sortColumnKey).
+     *
+     * @param imagePair: imagePair to get a column value out of.
+     */
+    $scope.getSortColumnValue = function(imagePair) {
+      if ($scope.sortColumnSubdict in imagePair) {
+        return imagePair[$scope.sortColumnSubdict][$scope.sortColumnKey];
+      } else {
+        return undefined;
+      }
+    }
+
+    /**
      * Set $scope.categoryValueMatch[name] = value, and update results.
      *
      * @param name
@@ -456,10 +497,13 @@
      * Tell the server that the actual results of these particular tests
      * are acceptable.
      *
-     * @param testDataSubset an array of test results, most likely a subset of
-     *        $scope.testData (perhaps with some modifications)
+     * TODO(epoger): This assumes that the original expectations are in
+     * imageSetA, and the actuals are in imageSetB.
+     *
+     * @param imagePairsSubset an array of test results, most likely a subset of
+     *        $scope.imagePairs (perhaps with some modifications)
      */
-    $scope.submitApprovals = function(testDataSubset) {
+    $scope.submitApprovals = function(imagePairsSubset) {
       $scope.submitPending = true;
 
       // Convert bug text field to null or 1-item array.
@@ -476,30 +520,40 @@
       // result type, RenderModeMismatch')
       var encounteredComparisonConfig = false;
 
-      var newResults = [];
-      for (var i = 0; i < testDataSubset.length; i++) {
-        var actualResult = testDataSubset[i];
-        var expectedResult = {
-          builder: actualResult['builder'],
-          test: actualResult['test'],
-          config: actualResult['config'],
-          expectedHashType: actualResult['actualHashType'],
-          expectedHashDigest: actualResult['actualHashDigest'],
-        };
-        if (0 == expectedResult.config.indexOf('comparison-')) {
+      var updatedExpectations = [];
+      for (var i = 0; i < imagePairsSubset.length; i++) {
+        var imagePair = imagePairsSubset[i];
+        var updatedExpectation = {};
+        updatedExpectation[constants.KEY__EXPECTATIONS_DATA] =
+            imagePair[constants.KEY__EXPECTATIONS_DATA];
+        updatedExpectation[constants.KEY__EXTRA_COLUMN_VALUES] =
+            imagePair[constants.KEY__EXTRA_COLUMN_VALUES];
+        updatedExpectation[constants.KEY__NEW_IMAGE_URL] =
+            imagePair[constants.KEY__IMAGE_B_URL];
+        if (0 == updatedExpectation[constants.KEY__EXTRA_COLUMN_VALUES]
+                                   [constants.KEY__EXTRACOLUMN__CONFIG]
+                                   .indexOf('comparison-')) {
           encounteredComparisonConfig = true;
         }
 
         // Advanced settings...
-        expectedResult['reviewed-by-human'] =
-            $scope.submitAdvancedSettings['reviewed-by-human'];
-        if (true == $scope.submitAdvancedSettings['ignore-failure']) {
-          // if it's false, don't send it at all (just keep the default)
-          expectedResult['ignore-failure'] = true;
+        if (null == updatedExpectation[constants.KEY__EXPECTATIONS_DATA]) {
+          updatedExpectation[constants.KEY__EXPECTATIONS_DATA] = {};
         }
-        expectedResult['bugs'] = bugs;
+        updatedExpectation[constants.KEY__EXPECTATIONS_DATA]
+                          [constants.KEY__EXPECTATIONS__REVIEWED] =
+            $scope.submitAdvancedSettings[
+                constants.KEY__EXPECTATIONS__REVIEWED];
+        if (true == $scope.submitAdvancedSettings[
+            constants.KEY__EXPECTATIONS__IGNOREFAILURE]) {
+          // if it's false, don't send it at all (just keep the default)
+          updatedExpectation[constants.KEY__EXPECTATIONS_DATA]
+                            [constants.KEY__EXPECTATIONS__IGNOREFAILURE] = true;
+        }
+        updatedExpectation[constants.KEY__EXPECTATIONS_DATA]
+                          [constants.KEY__EXPECTATIONS__BUGS] = bugs;
 
-        newResults.push(expectedResult);
+        updatedExpectations.push(updatedExpectation);
       }
       if (encounteredComparisonConfig) {
         alert("Approval failed -- you cannot approve results with config " +
@@ -507,21 +561,24 @@
         $scope.submitPending = false;
         return;
       }
+      var modificationData = {};
+      modificationData[constants.KEY__EDITS__MODIFICATIONS] =
+          updatedExpectations;
+      modificationData[constants.KEY__EDITS__OLD_RESULTS_HASH] =
+          $scope.header[constants.KEY__HEADER__DATAHASH];
+      modificationData[constants.KEY__EDITS__OLD_RESULTS_TYPE] =
+          $scope.header[constants.KEY__HEADER__TYPE];
       $http({
         method: "POST",
         url: "/edits",
-        data: {
-          oldResultsType: $scope.header.type,
-          oldResultsHash: $scope.header.dataHash,
-          modifications: newResults
-        }
+        data: modificationData
       }).success(function(data, status, headers, config) {
-        var itemIndicesToMove = [];
-        for (var i = 0; i < testDataSubset.length; i++) {
-          itemIndicesToMove.push(testDataSubset[i].index);
+        var imagePairIndicesToMove = [];
+        for (var i = 0; i < imagePairsSubset.length; i++) {
+          imagePairIndicesToMove.push(imagePairsSubset[i].index);
         }
-        $scope.moveItemsToTab(itemIndicesToMove,
-                              "HackToMakeSureThisItemDisappears");
+        $scope.moveImagePairsToTab(imagePairIndicesToMove,
+                                   "HackToMakeSureThisImagePairDisappears");
         $scope.updateResults();
         alert("New baselines submitted successfully!\n\n" +
             "You still need to commit the updated expectations files on " +
@@ -682,5 +739,23 @@
       return $scope.hexColorString(v, v, v);
     }
 
+    /**
+     * Returns the last path component of image diff URL for a given ImagePair.
+     *
+     * Depending on which diff this is (whitediffs, pixeldiffs, etc.) this
+     * will be relative to different base URLs.
+     *
+     * We must keep this function in sync with _get_difference_locator() in
+     * ../imagediffdb.py
+     *
+     * @param imagePair: ImagePair to generate image diff URL for
+     */
+    $scope.getImageDiffRelativeUrl = function(imagePair) {
+      var before =
+          imagePair[constants.KEY__IMAGE_A_URL] + "-vs-" +
+          imagePair[constants.KEY__IMAGE_B_URL];
+      return before.replace(/[^\w\-]/g, "_") + ".png";
+    }
+
   }
 );
diff --git a/gm/rebaseline_server/static/view.html b/gm/rebaseline_server/static/view.html
index 9ce3fae..36fca75 100644
--- a/gm/rebaseline_server/static/view.html
+++ b/gm/rebaseline_server/static/view.html
@@ -5,8 +5,9 @@
 <head>
   <title ng-bind="windowTitle"></title>
   <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.1.5/angular.js"></script>
-  <script src="loader.js"></script>
+  <script src="constants.js"></script>
   <script src="diff_viewer.js"></script>
+  <script src="loader.js"></script>
   <link rel="stylesheet" href="view.css">
 </head>
 
@@ -22,16 +23,16 @@
     {{loadingMessage}}
   </em>
 
-  <div ng-hide="!categories"><!-- everything: hide until data is loaded -->
+  <div ng-hide="!extraColumnHeaders"><!-- everything: hide until data is loaded -->
 
     <div class="warning-div"
-         ng-hide="!(header.isEditable && header.isExported)">
+         ng-hide="!(header[constants.KEY__HEADER__IS_EDITABLE] && header[constants.KEY__HEADER__IS_EXPORTED])">
       WARNING!  These results are editable and exported, so any user
       who can connect to this server over the network can modify them.
     </div>
 
-    <div ng-hide="!(header.timeUpdated)">
-      Results current as of {{localTimeString(header.timeUpdated)}}
+    <div ng-hide="!(header[constants.KEY__HEADER___TIME_UPDATED])">
+      Results current as of {{localTimeString(header[constants.KEY__HEADER__TIME_UPDATED])}}
     </div>
 
     <div><!-- tabs -->
@@ -60,10 +61,9 @@
       </th>
     </tr>
     <tr valign="top">
-      <!-- TODO(epoger): make this an ng-repeat over resultType, config, etc? -->
       <td>
         resultType<br>
-        <label ng-repeat="(resultType, count) in categories['resultType'] track by $index">
+        <label ng-repeat="(resultType, count) in extraColumnHeaders[constants.KEY__EXTRACOLUMN__RESULT_TYPE][constants.KEY__VALUES_AND_COUNTS] track by $index">
           <input type="checkbox"
                  name="resultTypes"
                  value="{{resultType}}"
@@ -81,7 +81,7 @@
           toggle
         </button>
       </td>
-      <td ng-repeat="category in ['builder', 'test']">
+      <td ng-repeat="category in [constants.KEY__EXTRACOLUMN__BUILDER, constants.KEY__EXTRACOLUMN__TEST]">
         {{category}}
         <br>
         <input type="text"
@@ -95,7 +95,7 @@
       </td>
       <td>
         config<br>
-        <label ng-repeat="(config, count) in categories['config'] track by $index">
+        <label ng-repeat="(config, count) in extraColumnHeaders[constants.KEY__EXTRACOLUMN__CONFIG][constants.KEY__VALUES_AND_COUNTS] track by $index">
           <input type="checkbox"
                  name="configs"
                  value="{{config}}"
@@ -145,9 +145,9 @@
       <div ng-hide="'Pending Approval' != viewingTab">
         <div style="display:inline-block">
           <button style="font-size:20px"
-                  ng-click="submitApprovals(filteredTestData)"
-                  ng-disabled="submitPending || (filteredTestData.length == 0)">
-            Update these {{filteredTestData.length}} expectations on the server
+                  ng-click="submitApprovals(filteredImagePairs)"
+                  ng-disabled="submitPending || (filteredImagePairs.length == 0)">
+            Update these {{filteredImagePairs.length}} expectations on the server
           </button>
         </div>
         <div style="display:inline-block">
@@ -161,7 +161,7 @@
           <input type="checkbox" ng-model="showSubmitAdvancedSettings">
           show
           <ul ng-hide="!showSubmitAdvancedSettings">
-            <li ng-repeat="setting in ['reviewed-by-human', 'ignore-failure']">
+            <li ng-repeat="setting in [constants.KEY__EXPECTATIONS__REVIEWED, constants.KEY__EXPECTATIONS__IGNOREFAILURE]">
               {{setting}}
               <input type="checkbox" ng-model="submitAdvancedSettings[setting]">
             </li>
@@ -179,11 +179,11 @@
       <table border="0" width="100%"> <!-- results header -->
         <tr>
           <td>
-            Found {{filteredTestData.length}} matches;
-            <span ng-hide="filteredTestData.length <= limitedTestData.length">
-              displaying the first {{limitedTestData.length}}
+            Found {{filteredImagePairs.length}} matches;
+            <span ng-hide="filteredImagePairs.length <= limitedImagePairs.length">
+              displaying the first {{limitedImagePairs.length}}
             </span>
-            <span ng-hide="filteredTestData.length > limitedTestData.length">
+            <span ng-hide="filteredImagePairs.length > limitedImagePairs.length">
               displaying them all
             </span>
             <br>
@@ -192,21 +192,21 @@
           <td align="right">
             <div>
               all tests shown:
-              <button ng-click="selectAllItems()">
+              <button ng-click="selectAllImagePairs()">
                 select
               </button>
-              <button ng-click="clearAllItems()">
+              <button ng-click="clearAllImagePairs()">
                 clear
               </button>
-              <button ng-click="toggleAllItems()">
+              <button ng-click="toggleAllImagePairs()">
                 toggle
               </button>
             </div>
             <div ng-repeat="otherTab in tabs">
-              <button ng-click="moveSelectedItemsToTab(otherTab)"
-                      ng-disabled="selectedItems.length == 0"
+              <button ng-click="moveSelectedImagePairsToTab(otherTab)"
+                      ng-disabled="selectedImagePairs.length == 0"
                       ng-hide="otherTab == viewingTab">
-                move {{selectedItems.length}} selected tests to {{otherTab}} tab
+                move {{selectedImagePairs.length}} selected tests to {{otherTab}} tab
               </button>
             </div>
           </td>
@@ -216,12 +216,12 @@
       <table border="1" ng-app="diff_viewer"> <!-- results -->
         <tr>
           <!-- Most column headers are displayed in a common fashion... -->
-          <th ng-repeat="categoryName in ['resultType', 'builder', 'test', 'config']">
+          <th ng-repeat="categoryName in [constants.KEY__EXTRACOLUMN__RESULT_TYPE, constants.KEY__EXTRACOLUMN__BUILDER, constants.KEY__EXTRACOLUMN__TEST, constants.KEY__EXTRACOLUMN__CONFIG]">
             <input type="radio"
                    name="sortColumnRadio"
                    value="{{categoryName}}"
-                   ng-checked="(sortColumn == categoryName)"
-                   ng-click="sortResultsBy(categoryName)">
+                   ng-checked="(sortColumnKey == categoryName)"
+                   ng-click="sortResultsBy(constants.KEY__EXTRA_COLUMN_VALUES, categoryName)">
             {{categoryName}}
           </th>
           <!-- ... but there are a few columns where we display things differently. -->
@@ -229,40 +229,30 @@
             <input type="radio"
                    name="sortColumnRadio"
                    value="bugs"
-                   ng-checked="(sortColumn == 'bugs')"
-                   ng-click="sortResultsBy('bugs')">
+                   ng-checked="(sortColumnKey == constants.KEY__EXPECTATIONS__BUGS)"
+                   ng-click="sortResultsBy(constants.KEY__EXPECTATIONS_DATA, constants.KEY__EXPECTATIONS__BUGS)">
             bugs
           </th>
           <th width="{{imageSize}}">
-            <input type="radio"
-                   name="sortColumnRadio"
-                   value="expectedHashDigest"
-                   ng-checked="(sortColumn == 'expectedHashDigest')"
-                   ng-click="sortResultsBy('expectedHashDigest')">
-            expected image
+            {{imageSets[0][constants.KEY__IMAGESETS__DESCRIPTION]}}
           </th>
           <th width="{{imageSize}}">
-            <input type="radio"
-                   name="sortColumnRadio"
-                   value="actualHashDigest"
-                   ng-checked="(sortColumn == 'actualHashDigest')"
-                   ng-click="sortResultsBy('actualHashDigest')">
-            actual image
+            {{imageSets[1][constants.KEY__IMAGESETS__DESCRIPTION]}}
           </th>
           <th width="{{imageSize}}">
             <input type="radio"
                    name="sortColumnRadio"
                    value="percentDifferingPixels"
-                   ng-checked="(sortColumn == 'percentDifferingPixels')"
-                   ng-click="sortResultsBy('percentDifferingPixels')">
+                   ng-checked="(sortColumnKey == constants.KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS)"
+                   ng-click="sortResultsBy(constants.KEY__DIFFERENCE_DATA, constants.KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS)">
             differing pixels in white
           </th>
           <th width="{{imageSize}}">
             <input type="radio"
                    name="sortColumnRadio"
                    value="weightedDiffMeasure"
-                   ng-checked="(sortColumn == 'weightedDiffMeasure')"
-                   ng-click="sortResultsBy('weightedDiffMeasure')">
+                   ng-checked="(sortColumnKey == constants.KEY__DIFFERENCE_DATA__WEIGHTED_DIFF)"
+                   ng-click="sortResultsBy(constants.KEY__DIFFERENCE_DATA, constants.KEY__DIFFERENCE_DATA__WEIGHTED_DIFF)">
             perceptual difference
             <br>
             <input type="range" ng-model="pixelDiffBgColorBrightness"
@@ -272,18 +262,18 @@
                    min="0" max="255"/>
           </th>
           <th>
-            <!-- item-selection checkbox column -->
+            <!-- imagepair-selection checkbox column -->
           </th>
         </tr>
 
-        <tr ng-repeat="result in limitedTestData" ng-controller="ImageController">
+        <tr ng-repeat="imagePair in limitedImagePairs" ng-controller="ImageController">
           <td>
-            {{result.resultType}}
+            {{imagePair[constants.KEY__EXTRA_COLUMN_VALUES][constants.KEY__EXTRACOLUMN__RESULT_TYPE]}}
             <br>
             <button class="show-only-button"
                     ng-hide="viewingTab != defaultTab"
-                    ng-click="showOnlyResultType(result.resultType)"
-                    title="show only results of type '{{result.resultType}}'">
+                    ng-click="showOnlyResultType(imagePair[constants.KEY__EXTRA_COLUMN_VALUES][constants.KEY__EXTRACOLUMN__RESULT_TYPE])"
+                    title="show only results of type {{imagePair[constants.KEY__EXTRA_COLUMN_VALUES][constants.KEY__EXTRACOLUMN__RESULT_TYPE]}}">
               show only
             </button>
             <br>
@@ -295,14 +285,14 @@
               show all
             </button>
           </td>
-          <td ng-repeat="categoryName in ['builder', 'test']">
-            {{result[categoryName]}}
+          <td ng-repeat="categoryName in [constants.KEY__EXTRACOLUMN__BUILDER, constants.KEY__EXTRACOLUMN__TEST]">
+            {{imagePair[constants.KEY__EXTRA_COLUMN_VALUES][categoryName]}}
             <br>
             <button class="show-only-button"
                     ng-hide="viewingTab != defaultTab"
-                    ng-disabled="result[categoryName] == categoryValueMatch[categoryName]"
-                    ng-click="setCategoryValueMatch(categoryName, result[categoryName])"
-                    title="show only results of {{categoryName}} '{{result[categoryName]}}'">
+                    ng-disabled="imagePair[constants.KEY__EXTRA_COLUMN_VALUES][categoryName] == categoryValueMatch[categoryName]"
+                    ng-click="setCategoryValueMatch(categoryName, imagePair[constants.KEY__EXTRA_COLUMN_VALUES][categoryName])"
+                    title="show only results of {{categoryName}} {{imagePair[constants.KEY__EXTRA_COLUMN_VALUES][categoryName]}}">
               show only
             </button>
             <br>
@@ -315,12 +305,12 @@
             </button>
           </td>
           <td>
-            {{result.config}}
+            {{imagePair[constants.KEY__EXTRA_COLUMN_VALUES][constants.KEY__EXTRACOLUMN__CONFIG]}}
             <br>
             <button class="show-only-button"
                     ng-hide="viewingTab != defaultTab"
-                    ng-click="showOnlyConfig(result.config)"
-                    title="show only results of config '{{result.config}}'">
+                    ng-click="showOnlyConfig(imagePair[constants.KEY__EXTRA_COLUMN_VALUES][constants.KEY__EXTRACOLUMN__CONFIG])"
+                    title="show only results of config {{imagePair[constants.KEY__EXTRA_COLUMN_VALUES][constants.KEY__EXTRACOLUMN__CONFIG]}}">
               show only
             </button>
             <br>
@@ -333,43 +323,41 @@
             </button>
           </td>
           <td>
-            <a ng-repeat="bug in result['bugs']"
+            <a ng-repeat="bug in imagePair[constants.KEY__EXPECTATIONS_DATA][constants.KEY__EXPECTATIONS__BUGS]"
                href="https://code.google.com/p/skia/issues/detail?id={{bug}}"
                target="_blank">
               {{bug}}
             </a>
           </td>
 
-          <!-- expected image -->
+          <!-- image A -->
           <td valign="bottom" width="{{imageSize}}">
-            <a href="http://chromium-skia-gm.commondatastorage.googleapis.com/gm/{{result.expectedHashType}}/{{result.test}}/{{result.expectedHashDigest}}.png" target="_blank">View Image</a><br/>
+            <a href="{{imageSets[0][constants.KEY__IMAGESETS__BASE_URL]}}/{{imagePair[constants.KEY__IMAGE_A_URL]}}" target="_blank">View Image</a><br/>
             <img-compare type="baseline" width="{{imageSize}}"
-                         src="http://chromium-skia-gm.commondatastorage.googleapis.com/gm/{{result.expectedHashType}}/{{result.test}}/{{result.expectedHashDigest}}.png" />
-
+                         src="{{imageSets[0][constants.KEY__IMAGESETS__BASE_URL]}}/{{imagePair[constants.KEY__IMAGE_A_URL]}}" />
           </td>
 
-          <!-- actual image -->
+          <!-- image B -->
           <td valign="bottom" width="{{imageSize}}">
-            <a href="http://chromium-skia-gm.commondatastorage.googleapis.com/gm/{{result.actualHashType}}/{{result.test}}/{{result.actualHashDigest}}.png" target="_blank">View Image</a><br/>
+            <a href="{{imageSets[1][constants.KEY__IMAGESETS__BASE_URL]}}/{{imagePair[constants.KEY__IMAGE_B_URL]}}" target="_blank">View Image</a><br/>
             <img-compare type="test" width="{{imageSize}}"
-                         src="http://chromium-skia-gm.commondatastorage.googleapis.com/gm/{{result.actualHashType}}/{{result.test}}/{{result.actualHashDigest}}.png" />
-
+                         src="{{imageSets[1][constants.KEY__IMAGESETS__BASE_URL]}}/{{imagePair[constants.KEY__IMAGE_B_URL]}}" />
           </td>
 
           <!-- whitediffs: every differing pixel shown in white -->
           <td valign="bottom" width="{{imageSize}}">
-            <div ng-hide="result.expectedHashDigest == result.actualHashDigest"
-                 title="{{result.numDifferingPixels | number:0}} of {{(100 * result.numDifferingPixels / result.percentDifferingPixels) | number:0}} pixels ({{result.percentDifferingPixels.toFixed(4)}}%) differ from expectation.">
+            <div ng-hide="!imagePair[constants.KEY__IS_DIFFERENT]"
+                 title="{{imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__NUM_DIFF_PIXELS] | number:0}} of {{(100 * imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__NUM_DIFF_PIXELS] / imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS]) | number:0}} pixels ({{imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS].toFixed(4)}}%) differ from expectation.">
 
-              {{result.percentDifferingPixels.toFixed(4)}}%
-              ({{result.numDifferingPixels}})
+              {{imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS].toFixed(4)}}%
+              ({{imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__NUM_DIFF_PIXELS]}})
               <br/>
-              <a href="/static/generated-images/whitediffs/{{result.expectedHashDigest}}-vs-{{result.actualHashDigest}}.png" target="_blank">View Image</a><br/>
+              <a href="/static/generated-images/whitediffs/{{getImageDiffRelativeUrl(imagePair)}}" target="_blank">View Image</a><br/>
               <img-compare type="differingPixelsInWhite" width="{{imageSize}}"
-                           src="/static/generated-images/whitediffs/{{result.expectedHashDigest}}-vs-{{result.actualHashDigest}}.png" />
+                           src="/static/generated-images/whitediffs/{{getImageDiffRelativeUrl(imagePair)}}" />
 
             </div>
-            <div ng-hide="result.expectedHashDigest != result.actualHashDigest"
+            <div ng-hide="imagePair[constants.KEY__IS_DIFFERENT]"
                  style="text-align:center">
               &ndash;none&ndash;
             </div>
@@ -377,23 +365,23 @@
 
           <!-- diffs: per-channel RGB deltas -->
           <td valign="bottom" width="{{imageSize}}">
-            <div ng-hide="result.expectedHashDigest == result.actualHashDigest"
-                 title="Perceptual difference measure is {{result.perceptualDifference.toFixed(4)}}%.  Maximum difference per channel: R={{result.maxDiffPerChannel[0]}}, G={{result.maxDiffPerChannel[1]}}, B={{result.maxDiffPerChannel[2]}}">
+            <div ng-hide="!imagePair[constants.KEY__IS_DIFFERENT]"
+                 title="Perceptual difference measure is {{imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__PERCEPTUAL_DIFF].toFixed(4)}}%.  Maximum difference per channel: R={{imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__MAX_DIFF_PER_CHANNEL][0]}}, G={{imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__MAX_DIFF_PER_CHANNEL][1]}}, B={{imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__MAX_DIFF_PER_CHANNEL][2]}}">
 
-              {{result.perceptualDifference.toFixed(4)}}%
-              {{result.maxDiffPerChannel}}
+              {{imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__PERCEPTUAL_DIFF].toFixed(4)}}%
+              {{imagePair[constants.KEY__DIFFERENCE_DATA][constants.KEY__DIFFERENCE_DATA__MAX_DIFF_PER_CHANNEL]}}
               <br/>
-              <a href="/static/generated-images/diffs/{{result.expectedHashDigest}}-vs-{{result.actualHashDigest}}.png" target="_blank">View Image</a><br/>
+              <a href="/static/generated-images/diffs/{{getImageDiffRelativeUrl(imagePair)}}" target="_blank">View Image</a><br/>
               <img-compare ng-style="{backgroundColor: pixelDiffBgColor}"
                            type="differencePerPixel" width="{{imageSize}}"
-                           src="/static/generated-images/diffs/{{result.expectedHashDigest}}-vs-{{result.actualHashDigest}}.png"
+                           src="/static/generated-images/diffs/{{getImageDiffRelativeUrl(imagePair)}}"
                            ng-mousedown="MagnifyDraw($event, true)"
                            ng-mousemove="MagnifyDraw($event, false)"
                            ng-mouseup="MagnifyEnd($event)"
                            ng-mouseleave="MagnifyEnd($event)" />
 
             </div>
-            <div ng-hide="result.expectedHashDigest != result.actualHashDigest"
+            <div ng-hide="imagePair[constants.KEY__IS_DIFFERENT]"
                  style="text-align:center">
               &ndash;none&ndash;
             </div>
@@ -402,23 +390,15 @@
           <td>
             <input type="checkbox"
                    name="rowSelect"
-                   value="{{result.index}}"
-                   ng-checked="isValueInArray(result.index, selectedItems)"
-                   ng-click="toggleValueInArray(result.index, selectedItems)">
+                   value="{{imagePair.index}}"
+                   ng-checked="isValueInArray(imagePair.index, selectedImagePairs)"
+                   ng-click="toggleValueInArray(imagePair.index, selectedImagePairs)">
         </tr>
-      </table> <!-- results -->
-    </td></tr></table> <!-- table holding results header + results table -->
+      </table> <!-- imagePairs -->
+    </td></tr></table> <!-- table holding results header + imagePairs table -->
 
   </div><!-- main display area of selected tab -->
   </div><!-- everything: hide until data is loaded -->
 
-  <!-- TODO(epoger): Can we get the base URLs (commondatastorage and
-       issues list) from
-       https://skia.googlesource.com/buildbot/+/master/site_config/global_variables.json ?
-       I tried importing the
-       http://skia.googlecode.com/svn/buildbot/skia_tools.js script and using
-       that to do so, but I got Access-Control-Allow-Origin errors.
-    -->
-
 </body>
 </html>
diff --git a/gm/rebaseline_server/testdata/outputs/actual/results_test.ResultsTest.test_gm/gm.json b/gm/rebaseline_server/testdata/outputs/actual/results_test.ResultsTest.test_gm/gm.json
deleted file mode 100644
index 9aef669..0000000
--- a/gm/rebaseline_server/testdata/outputs/actual/results_test.ResultsTest.test_gm/gm.json
+++ /dev/null
@@ -1,526 +0,0 @@
-{
-  "categories": {
-    "builder": {
-      "Test-Android-GalaxyNexus-SGX540-Arm7-Release": 13, 
-      "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug": 15
-    }, 
-    "config": {
-      "565": 9, 
-      "8888": 9, 
-      "gpu": 4, 
-      "pdf-mac": 3, 
-      "pdf-poppler": 3
-    }, 
-    "ignore-failure": {
-      "null": 12, 
-      "false": 16
-    }, 
-    "resultType": {
-      "failed": 1, 
-      "failure-ignored": 4, 
-      "no-comparison": 9, 
-      "succeeded": 14
-    }, 
-    "reviewed-by-human": {
-      "null": 16, 
-      "false": 12
-    }, 
-    "test": {
-      "3x3bitmaprect": 7, 
-      "aaclip": 2, 
-      "bigblurs": 7, 
-      "bitmapsource": 2, 
-      "displacement": 5, 
-      "filterbitmap_checkerboard_192_192": 2, 
-      "filterbitmap_checkerboard_32_2": 2, 
-      "texdata": 1
-    }
-  }, 
-  "testData": [
-    {
-      "actualHashDigest": "3695033638604474475", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "gpu", 
-      "expectedHashDigest": "2736593828543197285", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": [
-        128, 
-        128, 
-        128
-      ], 
-      "numDifferingPixels": 120000, 
-      "percentDifferingPixels": 75.0, 
-      "perceptualDifference": 50.122499999999995, 
-      "resultType": "failed", 
-      "reviewed-by-human": null, 
-      "test": "texdata", 
-      "weightedDiffMeasure": 8.413046264257337
-    }, 
-    {
-      "actualHashDigest": "4719210487426381700", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "13270012198930365496", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": [
-        222, 
-        223, 
-        222
-      ], 
-      "numDifferingPixels": 77691, 
-      "percentDifferingPixels": 17.593070652173914, 
-      "perceptualDifference": 100, 
-      "resultType": "failure-ignored", 
-      "reviewed-by-human": false, 
-      "test": "filterbitmap_checkerboard_192_192", 
-      "weightedDiffMeasure": 3.661184599893761
-    }, 
-    {
-      "actualHashDigest": "3154864687054945306", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "14746826424040775628", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": [
-        221, 
-        221, 
-        221
-      ], 
-      "numDifferingPixels": 82952, 
-      "percentDifferingPixels": 18.784420289855074, 
-      "perceptualDifference": 100, 
-      "resultType": "failure-ignored", 
-      "reviewed-by-human": false, 
-      "test": "filterbitmap_checkerboard_192_192", 
-      "weightedDiffMeasure": 3.6140912497422955
-    }, 
-    {
-      "actualHashDigest": "15528304435129737588", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "16197252621792695154", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": [
-        222, 
-        223, 
-        222
-      ], 
-      "numDifferingPixels": 53150, 
-      "percentDifferingPixels": 12.035778985507246, 
-      "perceptualDifference": 100, 
-      "resultType": "failure-ignored", 
-      "reviewed-by-human": false, 
-      "test": "filterbitmap_checkerboard_32_2", 
-      "weightedDiffMeasure": 3.713243437353155
-    }, 
-    {
-      "actualHashDigest": "712827739969462165", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "7634650333321761866", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": [
-        221, 
-        221, 
-        221
-      ], 
-      "numDifferingPixels": 53773, 
-      "percentDifferingPixels": 12.17685688405797, 
-      "perceptualDifference": 100, 
-      "resultType": "failure-ignored", 
-      "reviewed-by-human": false, 
-      "test": "filterbitmap_checkerboard_32_2", 
-      "weightedDiffMeasure": 3.6723483686597684
-    }, 
-    {
-      "actualHashDigest": "2422083043229439955", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
-    }, 
-    {
-      "actualHashDigest": "17309852422285247848", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
-    }, 
-    {
-      "actualHashDigest": "17503582803589749280", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bitmapsource"
-    }, 
-    {
-      "actualHashDigest": "16289727936158057543", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bitmapsource"
-    }, 
-    {
-      "actualHashDigest": "16998423976396106083", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "16998423976396106083", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "2054956815327187963", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "2054956815327187963", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "6190901827590820995", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "6190901827590820995", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "aaclip", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "14456211900777561488", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "14456211900777561488", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "aaclip", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "4569468668668628191", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "565", 
-      "expectedHashDigest": "4569468668668628191", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "displacement", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "11401048196735046263", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "8888", 
-      "expectedHashDigest": "11401048196735046263", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "displacement", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "5698561127291561694", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "gpu", 
-      "expectedHashDigest": "5698561127291561694", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "displacement", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "12901125495691049846", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-mac", 
-      "expectedHashDigest": "12901125495691049846", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "displacement", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "16285974094717334658", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-poppler", 
-      "expectedHashDigest": "16285974094717334658", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "displacement", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "14704206703218007573", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "565", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
-    }, 
-    {
-      "actualHashDigest": "17309852422285247848", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "8888", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
-    }, 
-    {
-      "actualHashDigest": "1822195599289208664", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "gpu", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
-    }, 
-    {
-      "actualHashDigest": "16171608477794909861", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-mac", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
-    }, 
-    {
-      "actualHashDigest": "6539050160610613353", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-poppler", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
-    }, 
-    {
-      "actualHashDigest": "16998423976396106083", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "565", 
-      "expectedHashDigest": "16998423976396106083", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "2054956815327187963", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "8888", 
-      "expectedHashDigest": "2054956815327187963", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "2054956815327187963", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "gpu", 
-      "expectedHashDigest": "2054956815327187963", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "8518347971308375604", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-mac", 
-      "expectedHashDigest": "8518347971308375604", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
-    }, 
-    {
-      "actualHashDigest": "16723580409414313678", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-poppler", 
-      "expectedHashDigest": "16723580409414313678", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
-    }
-  ]
-}
\ No newline at end of file
diff --git a/gm/rebaseline_server/testdata/outputs/expected/results_test.ResultsTest.test_gm/gm.json b/gm/rebaseline_server/testdata/outputs/expected/results_test.ResultsTest.test_gm/gm.json
index 9aef669..3821655 100644
--- a/gm/rebaseline_server/testdata/outputs/expected/results_test.ResultsTest.test_gm/gm.json
+++ b/gm/rebaseline_server/testdata/outputs/expected/results_test.ResultsTest.test_gm/gm.json
@@ -1,526 +1,545 @@
 {
-  "categories": {
+  "extraColumnHeaders": {
     "builder": {
-      "Test-Android-GalaxyNexus-SGX540-Arm7-Release": 13, 
-      "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug": 15
+      "headerText": "builder", 
+      "isFilterable": true, 
+      "isSortable": true, 
+      "valuesAndCounts": {
+        "Test-Android-GalaxyNexus-SGX540-Arm7-Release": 13, 
+        "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug": 15
+      }
     }, 
     "config": {
-      "565": 9, 
-      "8888": 9, 
-      "gpu": 4, 
-      "pdf-mac": 3, 
-      "pdf-poppler": 3
-    }, 
-    "ignore-failure": {
-      "null": 12, 
-      "false": 16
+      "headerText": "config", 
+      "isFilterable": true, 
+      "isSortable": true, 
+      "valuesAndCounts": {
+        "565": 9, 
+        "8888": 9, 
+        "gpu": 4, 
+        "pdf-mac": 3, 
+        "pdf-poppler": 3
+      }
     }, 
     "resultType": {
-      "failed": 1, 
-      "failure-ignored": 4, 
-      "no-comparison": 9, 
-      "succeeded": 14
-    }, 
-    "reviewed-by-human": {
-      "null": 16, 
-      "false": 12
+      "headerText": "resultType", 
+      "isFilterable": true, 
+      "isSortable": true, 
+      "valuesAndCounts": {
+        "failed": 1, 
+        "failure-ignored": 4, 
+        "no-comparison": 9, 
+        "succeeded": 14
+      }
     }, 
     "test": {
-      "3x3bitmaprect": 7, 
-      "aaclip": 2, 
-      "bigblurs": 7, 
-      "bitmapsource": 2, 
-      "displacement": 5, 
-      "filterbitmap_checkerboard_192_192": 2, 
-      "filterbitmap_checkerboard_32_2": 2, 
-      "texdata": 1
+      "headerText": "test", 
+      "isFilterable": true, 
+      "isSortable": true, 
+      "valuesAndCounts": {
+        "3x3bitmaprect": 7, 
+        "aaclip": 2, 
+        "bigblurs": 7, 
+        "bitmapsource": 2, 
+        "displacement": 5, 
+        "filterbitmap_checkerboard_192_192": 2, 
+        "filterbitmap_checkerboard_32_2": 2, 
+        "texdata": 1
+      }
     }
   }, 
-  "testData": [
+  "imagePairs": [
     {
-      "actualHashDigest": "3695033638604474475", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "gpu", 
-      "expectedHashDigest": "2736593828543197285", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": [
-        128, 
-        128, 
-        128
-      ], 
-      "numDifferingPixels": 120000, 
-      "percentDifferingPixels": 75.0, 
-      "perceptualDifference": 50.122499999999995, 
-      "resultType": "failed", 
-      "reviewed-by-human": null, 
-      "test": "texdata", 
-      "weightedDiffMeasure": 8.413046264257337
+      "differenceData": {
+        "maxDiffPerChannel": [
+          128, 
+          128, 
+          128
+        ], 
+        "numDifferingPixels": 120000, 
+        "percentDifferingPixels": 75.0, 
+        "perceptualDifference": 50.122499999999995, 
+        "weightedDiffMeasure": 8.413046264257337
+      }, 
+      "expectations": {
+        "bugs": null, 
+        "ignore-failure": false, 
+        "reviewed-by-human": null
+      }, 
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "gpu", 
+        "resultType": "failed", 
+        "test": "texdata"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/texdata/2736593828543197285.png", 
+      "imageBUrl": "bitmap-64bitMD5/texdata/3695033638604474475.png", 
+      "isDifferent": true
     }, 
     {
-      "actualHashDigest": "4719210487426381700", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "13270012198930365496", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": [
-        222, 
-        223, 
-        222
-      ], 
-      "numDifferingPixels": 77691, 
-      "percentDifferingPixels": 17.593070652173914, 
-      "perceptualDifference": 100, 
-      "resultType": "failure-ignored", 
-      "reviewed-by-human": false, 
-      "test": "filterbitmap_checkerboard_192_192", 
-      "weightedDiffMeasure": 3.661184599893761
+      "differenceData": {
+        "maxDiffPerChannel": [
+          222, 
+          223, 
+          222
+        ], 
+        "numDifferingPixels": 77691, 
+        "percentDifferingPixels": 17.593070652173914, 
+        "perceptualDifference": 100, 
+        "weightedDiffMeasure": 3.661184599893761
+      }, 
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "565", 
+        "resultType": "failure-ignored", 
+        "test": "filterbitmap_checkerboard_192_192"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/filterbitmap_checkerboard_192_192/13270012198930365496.png", 
+      "imageBUrl": "bitmap-64bitMD5/filterbitmap_checkerboard_192_192/4719210487426381700.png", 
+      "isDifferent": true
     }, 
     {
-      "actualHashDigest": "3154864687054945306", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "14746826424040775628", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": [
-        221, 
-        221, 
-        221
-      ], 
-      "numDifferingPixels": 82952, 
-      "percentDifferingPixels": 18.784420289855074, 
-      "perceptualDifference": 100, 
-      "resultType": "failure-ignored", 
-      "reviewed-by-human": false, 
-      "test": "filterbitmap_checkerboard_192_192", 
-      "weightedDiffMeasure": 3.6140912497422955
+      "differenceData": {
+        "maxDiffPerChannel": [
+          221, 
+          221, 
+          221
+        ], 
+        "numDifferingPixels": 82952, 
+        "percentDifferingPixels": 18.784420289855074, 
+        "perceptualDifference": 100, 
+        "weightedDiffMeasure": 3.6140912497422955
+      }, 
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "8888", 
+        "resultType": "failure-ignored", 
+        "test": "filterbitmap_checkerboard_192_192"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/filterbitmap_checkerboard_192_192/14746826424040775628.png", 
+      "imageBUrl": "bitmap-64bitMD5/filterbitmap_checkerboard_192_192/3154864687054945306.png", 
+      "isDifferent": true
     }, 
     {
-      "actualHashDigest": "15528304435129737588", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "16197252621792695154", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": [
-        222, 
-        223, 
-        222
-      ], 
-      "numDifferingPixels": 53150, 
-      "percentDifferingPixels": 12.035778985507246, 
-      "perceptualDifference": 100, 
-      "resultType": "failure-ignored", 
-      "reviewed-by-human": false, 
-      "test": "filterbitmap_checkerboard_32_2", 
-      "weightedDiffMeasure": 3.713243437353155
+      "differenceData": {
+        "maxDiffPerChannel": [
+          222, 
+          223, 
+          222
+        ], 
+        "numDifferingPixels": 53150, 
+        "percentDifferingPixels": 12.035778985507246, 
+        "perceptualDifference": 100, 
+        "weightedDiffMeasure": 3.713243437353155
+      }, 
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "565", 
+        "resultType": "failure-ignored", 
+        "test": "filterbitmap_checkerboard_32_2"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/filterbitmap_checkerboard_32_2/16197252621792695154.png", 
+      "imageBUrl": "bitmap-64bitMD5/filterbitmap_checkerboard_32_2/15528304435129737588.png", 
+      "isDifferent": true
     }, 
     {
-      "actualHashDigest": "712827739969462165", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "7634650333321761866", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": [
-        221, 
-        221, 
-        221
-      ], 
-      "numDifferingPixels": 53773, 
-      "percentDifferingPixels": 12.17685688405797, 
-      "perceptualDifference": 100, 
-      "resultType": "failure-ignored", 
-      "reviewed-by-human": false, 
-      "test": "filterbitmap_checkerboard_32_2", 
-      "weightedDiffMeasure": 3.6723483686597684
+      "differenceData": {
+        "maxDiffPerChannel": [
+          221, 
+          221, 
+          221
+        ], 
+        "numDifferingPixels": 53773, 
+        "percentDifferingPixels": 12.17685688405797, 
+        "perceptualDifference": 100, 
+        "weightedDiffMeasure": 3.6723483686597684
+      }, 
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "8888", 
+        "resultType": "failure-ignored", 
+        "test": "filterbitmap_checkerboard_32_2"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/filterbitmap_checkerboard_32_2/7634650333321761866.png", 
+      "imageBUrl": "bitmap-64bitMD5/filterbitmap_checkerboard_32_2/712827739969462165.png", 
+      "isDifferent": true
     }, 
     {
-      "actualHashDigest": "2422083043229439955", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "565", 
+        "resultType": "no-comparison", 
+        "test": "bigblurs"
+      }, 
+      "imageAUrl": null, 
+      "imageBUrl": "bitmap-64bitMD5/bigblurs/2422083043229439955.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "17309852422285247848", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "8888", 
+        "resultType": "no-comparison", 
+        "test": "bigblurs"
+      }, 
+      "imageAUrl": null, 
+      "imageBUrl": "bitmap-64bitMD5/bigblurs/17309852422285247848.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "17503582803589749280", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bitmapsource"
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "565", 
+        "resultType": "no-comparison", 
+        "test": "bitmapsource"
+      }, 
+      "imageAUrl": null, 
+      "imageBUrl": "bitmap-64bitMD5/bitmapsource/17503582803589749280.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "16289727936158057543", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bitmapsource"
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "8888", 
+        "resultType": "no-comparison", 
+        "test": "bitmapsource"
+      }, 
+      "imageAUrl": null, 
+      "imageBUrl": "bitmap-64bitMD5/bitmapsource/16289727936158057543.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "16998423976396106083", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "16998423976396106083", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "565", 
+        "resultType": "succeeded", 
+        "test": "3x3bitmaprect"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/3x3bitmaprect/16998423976396106083.png", 
+      "imageBUrl": "bitmap-64bitMD5/3x3bitmaprect/16998423976396106083.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "2054956815327187963", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "2054956815327187963", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "8888", 
+        "resultType": "succeeded", 
+        "test": "3x3bitmaprect"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/3x3bitmaprect/2054956815327187963.png", 
+      "imageBUrl": "bitmap-64bitMD5/3x3bitmaprect/2054956815327187963.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "6190901827590820995", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "565", 
-      "expectedHashDigest": "6190901827590820995", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "aaclip", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": null, 
+        "ignore-failure": false, 
+        "reviewed-by-human": null
+      }, 
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "565", 
+        "resultType": "succeeded", 
+        "test": "aaclip"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/aaclip/6190901827590820995.png", 
+      "imageBUrl": "bitmap-64bitMD5/aaclip/6190901827590820995.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "14456211900777561488", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
-      "config": "8888", 
-      "expectedHashDigest": "14456211900777561488", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "aaclip", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": null, 
+        "ignore-failure": false, 
+        "reviewed-by-human": null
+      }, 
+      "extraColumns": {
+        "builder": "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
+        "config": "8888", 
+        "resultType": "succeeded", 
+        "test": "aaclip"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/aaclip/14456211900777561488.png", 
+      "imageBUrl": "bitmap-64bitMD5/aaclip/14456211900777561488.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "4569468668668628191", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "565", 
-      "expectedHashDigest": "4569468668668628191", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "displacement", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": null, 
+        "ignore-failure": false, 
+        "reviewed-by-human": null
+      }, 
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "565", 
+        "resultType": "succeeded", 
+        "test": "displacement"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/displacement/4569468668668628191.png", 
+      "imageBUrl": "bitmap-64bitMD5/displacement/4569468668668628191.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "11401048196735046263", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "8888", 
-      "expectedHashDigest": "11401048196735046263", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "displacement", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": null, 
+        "ignore-failure": false, 
+        "reviewed-by-human": null
+      }, 
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "8888", 
+        "resultType": "succeeded", 
+        "test": "displacement"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/displacement/11401048196735046263.png", 
+      "imageBUrl": "bitmap-64bitMD5/displacement/11401048196735046263.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "5698561127291561694", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "gpu", 
-      "expectedHashDigest": "5698561127291561694", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "displacement", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": null, 
+        "ignore-failure": false, 
+        "reviewed-by-human": null
+      }, 
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "gpu", 
+        "resultType": "succeeded", 
+        "test": "displacement"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/displacement/5698561127291561694.png", 
+      "imageBUrl": "bitmap-64bitMD5/displacement/5698561127291561694.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "12901125495691049846", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": null, 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-mac", 
-      "expectedHashDigest": "12901125495691049846", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": false, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": null, 
-      "test": "displacement", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": null, 
+        "ignore-failure": false, 
+        "reviewed-by-human": null
+      }, 
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "pdf-mac", 
+        "resultType": "succeeded", 
+        "test": "displacement"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/displacement/12901125495691049846.png", 
+      "imageBUrl": "bitmap-64bitMD5/displacement/12901125495691049846.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "16285974094717334658", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-poppler", 
-      "expectedHashDigest": "16285974094717334658", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "displacement", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "pdf-poppler", 
+        "resultType": "succeeded", 
+        "test": "displacement"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/displacement/16285974094717334658.png", 
+      "imageBUrl": "bitmap-64bitMD5/displacement/16285974094717334658.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "14704206703218007573", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "565", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "565", 
+        "resultType": "no-comparison", 
+        "test": "bigblurs"
+      }, 
+      "imageAUrl": null, 
+      "imageBUrl": "bitmap-64bitMD5/bigblurs/14704206703218007573.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "17309852422285247848", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "8888", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "8888", 
+        "resultType": "no-comparison", 
+        "test": "bigblurs"
+      }, 
+      "imageAUrl": null, 
+      "imageBUrl": "bitmap-64bitMD5/bigblurs/17309852422285247848.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "1822195599289208664", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "gpu", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "gpu", 
+        "resultType": "no-comparison", 
+        "test": "bigblurs"
+      }, 
+      "imageAUrl": null, 
+      "imageBUrl": "bitmap-64bitMD5/bigblurs/1822195599289208664.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "16171608477794909861", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-mac", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "pdf-mac", 
+        "resultType": "no-comparison", 
+        "test": "bigblurs"
+      }, 
+      "imageAUrl": null, 
+      "imageBUrl": "bitmap-64bitMD5/bigblurs/16171608477794909861.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "6539050160610613353", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-poppler", 
-      "expectedHashDigest": "None", 
-      "expectedHashType": null, 
-      "ignore-failure": false, 
-      "resultType": "no-comparison", 
-      "test": "bigblurs"
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "pdf-poppler", 
+        "resultType": "no-comparison", 
+        "test": "bigblurs"
+      }, 
+      "imageAUrl": null, 
+      "imageBUrl": "bitmap-64bitMD5/bigblurs/6539050160610613353.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "16998423976396106083", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "565", 
-      "expectedHashDigest": "16998423976396106083", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "565", 
+        "resultType": "succeeded", 
+        "test": "3x3bitmaprect"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/3x3bitmaprect/16998423976396106083.png", 
+      "imageBUrl": "bitmap-64bitMD5/3x3bitmaprect/16998423976396106083.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "2054956815327187963", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "8888", 
-      "expectedHashDigest": "2054956815327187963", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "8888", 
+        "resultType": "succeeded", 
+        "test": "3x3bitmaprect"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/3x3bitmaprect/2054956815327187963.png", 
+      "imageBUrl": "bitmap-64bitMD5/3x3bitmaprect/2054956815327187963.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "2054956815327187963", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "gpu", 
-      "expectedHashDigest": "2054956815327187963", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "gpu", 
+        "resultType": "succeeded", 
+        "test": "3x3bitmaprect"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/3x3bitmaprect/2054956815327187963.png", 
+      "imageBUrl": "bitmap-64bitMD5/3x3bitmaprect/2054956815327187963.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "8518347971308375604", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-mac", 
-      "expectedHashDigest": "8518347971308375604", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "pdf-mac", 
+        "resultType": "succeeded", 
+        "test": "3x3bitmaprect"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/3x3bitmaprect/8518347971308375604.png", 
+      "imageBUrl": "bitmap-64bitMD5/3x3bitmaprect/8518347971308375604.png", 
+      "isDifferent": false
     }, 
     {
-      "actualHashDigest": "16723580409414313678", 
-      "actualHashType": "bitmap-64bitMD5", 
-      "bugs": [
-        1578
-      ], 
-      "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
-      "config": "pdf-poppler", 
-      "expectedHashDigest": "16723580409414313678", 
-      "expectedHashType": "bitmap-64bitMD5", 
-      "ignore-failure": null, 
-      "maxDiffPerChannel": 0, 
-      "numDifferingPixels": 0, 
-      "percentDifferingPixels": 0, 
-      "perceptualDifference": 0, 
-      "resultType": "succeeded", 
-      "reviewed-by-human": false, 
-      "test": "3x3bitmaprect", 
-      "weightedDiffMeasure": 0
+      "expectations": {
+        "bugs": [
+          1578
+        ], 
+        "ignore-failure": null, 
+        "reviewed-by-human": false
+      }, 
+      "extraColumns": {
+        "builder": "Test-Mac10.7-MacMini4.1-GeForce320M-x86_64-Debug", 
+        "config": "pdf-poppler", 
+        "resultType": "succeeded", 
+        "test": "3x3bitmaprect"
+      }, 
+      "imageAUrl": "bitmap-64bitMD5/3x3bitmaprect/16723580409414313678.png", 
+      "imageBUrl": "bitmap-64bitMD5/3x3bitmaprect/16723580409414313678.png", 
+      "isDifferent": false
+    }
+  ], 
+  "imageSets": [
+    {
+      "baseUrl": "http://chromium-skia-gm.commondatastorage.googleapis.com/gm", 
+      "description": "expected image"
+    }, 
+    {
+      "baseUrl": "http://chromium-skia-gm.commondatastorage.googleapis.com/gm", 
+      "description": "actual image"
     }
   ]
 }
\ No newline at end of file