diff options
author | commit-bot@chromium.org <commit-bot@chromium.org@2bbb7eff-a529-9590-31e7-b0007b416f81> | 2014-02-13 17:17:05 +0000 |
---|---|---|
committer | commit-bot@chromium.org <commit-bot@chromium.org@2bbb7eff-a529-9590-31e7-b0007b416f81> | 2014-02-13 17:17:05 +0000 |
commit | 536b15ffb05a20f3681ed72749a3bc09c5386c10 (patch) | |
tree | f51bb863e0764cd5088652f43fde63ac61fbbb81 /gm/rebaseline_server | |
parent | ed3bb1a3a5deab5b6d465466b85d5c45db561ca5 (diff) |
rebaseline_server: create ImagePairSet-- holds a number of ImagePairs to examine
See https://goto.google.com/ChangingRbsJson and bug 1919 for additional context
BUG=skia:1919
NOTRY=True
R=rmistry@google.com
Author: epoger@google.com
Review URL: https://codereview.chromium.org/139343018
git-svn-id: http://skia.googlecode.com/svn/trunk@13434 2bbb7eff-a529-9590-31e7-b0007b416f81
Diffstat (limited to 'gm/rebaseline_server')
-rw-r--r-- | gm/rebaseline_server/column.py | 64 | ||||
-rw-r--r-- | gm/rebaseline_server/imagepair.py | 36 | ||||
-rwxr-xr-x | gm/rebaseline_server/imagepair_test.py | 10 | ||||
-rw-r--r-- | gm/rebaseline_server/imagepairset.py | 122 | ||||
-rwxr-xr-x | gm/rebaseline_server/imagepairset_test.py | 173 |
5 files changed, 381 insertions, 24 deletions
diff --git a/gm/rebaseline_server/column.py b/gm/rebaseline_server/column.py new file mode 100644 index 0000000000..7bce15a250 --- /dev/null +++ b/gm/rebaseline_server/column.py @@ -0,0 +1,64 @@ +#!/usr/bin/python + +""" +Copyright 2014 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. + +ColumnHeaderFactory class (see class docstring for details) +""" + +# Keys used within dictionary representation of each column header. +KEY__HEADER_TEXT = 'headerText' +KEY__HEADER_URL = 'headerUrl' +KEY__IS_FILTERABLE = 'isFilterable' +KEY__IS_SORTABLE = 'isSortable' +KEY__VALUES_AND_COUNTS = 'valuesAndCounts' + + +class ColumnHeaderFactory(object): + """Factory which assembles the header for a single column of data.""" + + def __init__(self, header_text, header_url=None, + is_filterable=True, is_sortable=True, + include_values_and_counts=True): + """ + Args: + header_text: string; text the client should display within column header. + header_url: string; target URL if user clicks on column header. + If None, nothing to click on. + is_filterable: boolean; whether client should allow filtering on this + column. + is_sortable: boolean; whether client should allow sorting on this column. + include_values_and_counts: boolean; whether the set of values found + within this column, and their counts, should be available for the + client to display. + """ + self._header_text = header_text + self._header_url = header_url + self._is_filterable = is_filterable + self._is_sortable = is_sortable + self._include_values_and_counts = include_values_and_counts + + def create_as_dict(self, values_and_counts_dict=None): + """Creates the header for this column, in dictionary form. + + Creates the header for this column in dictionary form, as needed when + constructing the JSON representation. Uses the KEY__* constants as keys. + + Args: + values_and_counts_dict: dictionary mapping each possible column value + to its count (how many entries in the column have this value), or + None if this information is not available. + """ + asdict = { + KEY__HEADER_TEXT: self._header_text, + KEY__IS_FILTERABLE: self._is_filterable, + KEY__IS_SORTABLE: self._is_sortable, + } + if self._header_url: + asdict[KEY__HEADER_URL] = self._header_url + if self._include_values_and_counts and values_and_counts_dict: + asdict[KEY__VALUES_AND_COUNTS] = values_and_counts_dict + return asdict diff --git a/gm/rebaseline_server/imagepair.py b/gm/rebaseline_server/imagepair.py index 1c71bd989c..bba36fa8c4 100644 --- a/gm/rebaseline_server/imagepair.py +++ b/gm/rebaseline_server/imagepair.py @@ -12,18 +12,16 @@ ImagePair class (see class docstring for details) import posixpath # Keys used within ImagePair dictionary representations. -KEY_DIFFERENCE_DATA = 'differenceData' -KEY_EXPECTATIONS_DATA = 'expectationsData' -KEY_EXTRA_COLUMN_VALUES = 'extraColumnValues' -KEY_IMAGE_A_URL = 'imageAUrl' -KEY_IMAGE_B_URL = 'imageBUrl' -KEY_IS_DIFFERENT = 'isDifferent' +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' class ImagePair(object): - """ - Describes a pair of images, along with optional metadata (pixel difference - metrics, whether to ignore mismatches, etc.) + """Describes a pair of images, pixel difference info, and optional metadata. """ def __init__(self, image_diff_db, @@ -63,21 +61,21 @@ class ImagePair(object): actual_image_locator=imageB_relative_url) def as_dict(self): - """ - Return a dictionary describing this ImagePair, as needed when constructing - the JSON representation. Uses the KEY_* constants as keys. + """Returns a dictionary describing this ImagePair. + + Uses the KEY__* constants as keys. """ asdict = { - KEY_IMAGE_A_URL: self.imageA_relative_url, - KEY_IMAGE_B_URL: self.imageB_relative_url, + KEY__IMAGE_A_URL: self.imageA_relative_url, + KEY__IMAGE_B_URL: self.imageB_relative_url, } if self.expectations_dict: - asdict[KEY_EXPECTATIONS_DATA] = self.expectations_dict + asdict[KEY__EXPECTATIONS_DATA] = self.expectations_dict if self.extra_columns_dict: - asdict[KEY_EXTRA_COLUMN_VALUES] = self.extra_columns_dict + asdict[KEY__EXTRA_COLUMN_VALUES] = self.extra_columns_dict if self.diff_record and (self.diff_record.get_num_pixels_differing() > 0): - asdict[KEY_IS_DIFFERENT] = True - asdict[KEY_DIFFERENCE_DATA] = self.diff_record.as_dict() + asdict[KEY__IS_DIFFERENT] = True + asdict[KEY__DIFFERENCE_DATA] = self.diff_record.as_dict() else: - asdict[KEY_IS_DIFFERENT] = False + asdict[KEY__IS_DIFFERENT] = False return asdict diff --git a/gm/rebaseline_server/imagepair_test.py b/gm/rebaseline_server/imagepair_test.py index fc1f2759e7..d29438e530 100755 --- a/gm/rebaseline_server/imagepair_test.py +++ b/gm/rebaseline_server/imagepair_test.py @@ -32,11 +32,11 @@ class ImagePairTest(unittest.TestCase): shutil.rmtree(self._temp_dir) def shortDescription(self): - """Tell unittest framework to not print docstrings for test cases.""" + """Tells unittest framework to not print docstrings for test cases.""" return None def test_endToEnd(self): - """Test ImagePair, using a real ImageDiffDB to download real images. + """Tests ImagePair, using a real ImageDiffDB to download real images. TODO(epoger): Either in addition to or instead of this end-to-end test, we should perform some tests using either: @@ -65,7 +65,7 @@ class ImagePairTest(unittest.TestCase): }, # expected output: { - 'extraColumnValues': { + 'extraColumns': { 'builder': 'MyBuilder', 'test': 'MyTest', }, @@ -115,11 +115,11 @@ class ImagePairTest(unittest.TestCase): 'percentDifferingPixels': 100.00, 'weightedDiffMeasure': 66.66666666666667, }, - 'expectationsData': { + 'expectations': { 'bugs': [1001, 1002], 'ignoreFailure': True, }, - 'extraColumnValues': { + 'extraColumns': { 'builder': 'MyBuilder', 'test': 'MyTest', }, diff --git a/gm/rebaseline_server/imagepairset.py b/gm/rebaseline_server/imagepairset.py new file mode 100644 index 0000000000..2e173f537f --- /dev/null +++ b/gm/rebaseline_server/imagepairset.py @@ -0,0 +1,122 @@ +#!/usr/bin/python + +""" +Copyright 2014 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. + +ImagePairSet class; see its docstring below. +""" + +import column + +# Keys used within dictionary representation of ImagePairSet. +KEY__COLUMNHEADERS = 'columnHeaders' +KEY__IMAGEPAIRS = 'imagePairs' +KEY__IMAGESETS = 'imageSets' +KEY__IMAGESETS__BASE_URL = 'baseUrl' +KEY__IMAGESETS__DESCRIPTION = 'description' + +DEFAULT_DESCRIPTIONS = ('setA', 'setB') + + +class ImagePairSet(object): + """A collection of ImagePairs, representing two arbitrary sets of images. + + These could be: + - images generated before and after a code patch + - expected and actual images for some tests + - or any other pairwise set of images. + """ + + def __init__(self, descriptions=None): + """ + Args: + descriptions: a (string, string) tuple describing the two image sets. + If not specified, DEFAULT_DESCRIPTIONS will be used. + """ + self._column_header_factories = {} + self._descriptions = descriptions or DEFAULT_DESCRIPTIONS + self._extra_column_tallies = {} # maps column_id -> values + # -> instances_per_value + self._image_pair_dicts = [] + + def add_image_pair(self, image_pair): + """Adds an ImagePair; this may be repeated any number of times.""" + # Special handling when we add the first ImagePair... + if not self._image_pair_dicts: + self._base_url = image_pair.base_url + + if image_pair.base_url != self._base_url: + raise Exception('added ImagePair with base_url "%s" instead of "%s"' % ( + image_pair.base_url, self._base_url)) + self._image_pair_dicts.append(image_pair.as_dict()) + 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) + + def set_column_header_factory(self, column_id, column_header_factory): + """Overrides the default settings for one of the extraColumn headers. + + Args: + column_id: string; unique ID of this column (must match a key within + an ImagePair's extra_columns dictionary) + column_header_factory: a ColumnHeaderFactory object + """ + self._column_header_factories[column_id] = column_header_factory + + def get_column_header_factory(self, column_id): + """Returns the ColumnHeaderFactory object for a particular extraColumn. + + Args: + column_id: string; unique ID of this column (must match a key within + an ImagePair's extra_columns dictionary) + """ + column_header_factory = self._column_header_factories.get(column_id, None) + if not column_header_factory: + column_header_factory = column.ColumnHeaderFactory(header_text=column_id) + self._column_header_factories[column_id] = column_header_factory + return column_header_factory + + def _add_extra_column_entry(self, column_id, value): + """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). + """ + 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 + known_values_for_column[value] = instances_of_this_value + + def _column_headers_as_dict(self): + """Returns all column headers as a dictionary.""" + asdict = {} + for column_id, values_for_column in self._extra_column_tallies.iteritems(): + column_header_factory = self.get_column_header_factory(column_id) + asdict[column_id] = column_header_factory.create_as_dict( + values_for_column) + return asdict + + def as_dict(self): + """Returns a dictionary describing this package of ImagePairs. + + Uses the KEY__* constants as keys. + """ + return { + KEY__COLUMNHEADERS: self._column_headers_as_dict(), + KEY__IMAGEPAIRS: self._image_pair_dicts, + KEY__IMAGESETS: [{ + KEY__IMAGESETS__BASE_URL: self._base_url, + KEY__IMAGESETS__DESCRIPTION: self._descriptions[0], + }, { + KEY__IMAGESETS__BASE_URL: self._base_url, + KEY__IMAGESETS__DESCRIPTION: self._descriptions[1], + }], + } diff --git a/gm/rebaseline_server/imagepairset_test.py b/gm/rebaseline_server/imagepairset_test.py new file mode 100755 index 0000000000..8f1edfce40 --- /dev/null +++ b/gm/rebaseline_server/imagepairset_test.py @@ -0,0 +1,173 @@ +#!/usr/bin/python + +""" +Copyright 2014 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. + +Test imagepairset.py +""" + +# System-level imports +import unittest + +# Local imports +import column +import imagepair +import imagepairset + + +BASE_URL_1 = 'http://base/url/1' +BASE_URL_2 = 'http://base/url/2' +IMAGEPAIR_1_AS_DICT = { + imagepair.KEY__EXTRA_COLUMN_VALUES: { + 'builder': 'MyBuilder', + 'test': 'test1', + }, + imagepair.KEY__IMAGE_A_URL: 'test1/1111.png', + imagepair.KEY__IMAGE_B_URL: 'test1/1111.png', + imagepair.KEY__IS_DIFFERENT: False, +} +IMAGEPAIR_2_AS_DICT = { + imagepair.KEY__DIFFERENCE_DATA: { + 'maxDiffPerChannel': [1, 2, 3], + 'numDifferingPixels': 111, + 'percentDifferingPixels': 22.222, + 'weightedDiffMeasure': 33.333, + }, + imagepair.KEY__EXTRA_COLUMN_VALUES: { + 'builder': 'MyBuilder', + 'test': 'test2', + }, + imagepair.KEY__IMAGE_A_URL: 'test2/2222.png', + imagepair.KEY__IMAGE_B_URL: 'test2/22223.png', + imagepair.KEY__IS_DIFFERENT: True, +} +IMAGEPAIR_3_AS_DICT = { + imagepair.KEY__DIFFERENCE_DATA: { + 'maxDiffPerChannel': [4, 5, 6], + 'numDifferingPixels': 111, + 'percentDifferingPixels': 44.444, + 'weightedDiffMeasure': 33.333, + }, + imagepair.KEY__EXPECTATIONS_DATA: { + 'bugs': [1001, 1002], + 'ignoreFailure': True, + }, + imagepair.KEY__EXTRA_COLUMN_VALUES: { + 'builder': 'MyBuilder', + 'test': 'test3', + }, + imagepair.KEY__IMAGE_A_URL: 'test3/3333.png', + imagepair.KEY__IMAGE_B_URL: 'test3/33334.png', + imagepair.KEY__IS_DIFFERENT: True, +} +SET_A_DESCRIPTION = 'expectations' +SET_B_DESCRIPTION = 'actuals' + + +class ImagePairSetTest(unittest.TestCase): + + def setUp(self): + self.maxDiff = None # do not truncate diffs when tests fail + + def shortDescription(self): + """Tells unittest framework to not print docstrings for test cases.""" + return None + + def test_success(self): + """Assembles some ImagePairs into an ImagePairSet, and validates results. + """ + image_pairs = [ + MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_1_AS_DICT), + MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_2_AS_DICT), + MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_3_AS_DICT), + ] + expected_imageset_dict = { + 'columnHeaders': { + 'builder': { + 'headerText': 'builder', + 'isFilterable': True, + 'isSortable': True, + 'valuesAndCounts': { + 'MyBuilder': 3 + }, + }, + 'test': { + 'headerText': 'which GM test', + 'headerUrl': 'http://learn/about/gm/tests', + 'isFilterable': True, + 'isSortable': False, + }, + }, + 'imagePairs': [ + IMAGEPAIR_1_AS_DICT, + IMAGEPAIR_2_AS_DICT, + IMAGEPAIR_3_AS_DICT, + ], + 'imageSets': [ + { + 'baseUrl': BASE_URL_1, + 'description': SET_A_DESCRIPTION, + }, + { + 'baseUrl': BASE_URL_1, + 'description': SET_B_DESCRIPTION, + }, + ], + } + + image_pair_set = imagepairset.ImagePairSet( + descriptions=(SET_A_DESCRIPTION, SET_B_DESCRIPTION)) + for image_pair in image_pairs: + image_pair_set.add_image_pair(image_pair) + # The 'builder' column header uses the default settings, + # but the 'test' column header has manual adjustments. + image_pair_set.set_column_header_factory( + 'test', + column.ColumnHeaderFactory( + header_text='which GM test', + header_url='http://learn/about/gm/tests', + is_filterable=True, + is_sortable=False, + include_values_and_counts=False)) + self.assertEqual(image_pair_set.as_dict(), expected_imageset_dict) + + def test_mismatched_base_url(self): + """Confirms that mismatched base_urls will cause an exception.""" + image_pair_set = imagepairset.ImagePairSet() + image_pair_set.add_image_pair( + MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_1_AS_DICT)) + image_pair_set.add_image_pair( + MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_2_AS_DICT)) + with self.assertRaises(Exception): + image_pair_set.add_image_pair( + MockImagePair(base_url=BASE_URL_2, + dict_to_return=IMAGEPAIR_3_AS_DICT)) + + +class MockImagePair(object): + """Mock ImagePair object, which will return canned results.""" + def __init__(self, base_url, dict_to_return): + """ + Args: + base_url: base_url attribute for this object + dict_to_return: dictionary to return from as_dict() + """ + self.base_url = base_url + self.extra_columns_dict = dict_to_return.get( + imagepair.KEY__EXTRA_COLUMN_VALUES, None) + self._dict_to_return = dict_to_return + + def as_dict(self): + return self._dict_to_return + + +def main(): + suite = unittest.TestLoader().loadTestsFromTestCase(ImagePairSetTest) + unittest.TextTestRunner(verbosity=2).run(suite) + + +if __name__ == '__main__': + main() |