1 #!/usr/bin/python 2 3 """ 4 Copyright 2014 Google Inc. 5 6 Use of this source code is governed by a BSD-style license that can be 7 found in the LICENSE file. 8 9 ImagePairSet class; see its docstring below. 10 """ 11 12 # System-level imports 13 import posixpath 14 15 # Must fix up PYTHONPATH before importing from within Skia 16 import rs_fixpypath # pylint: disable=W0611 17 18 # Imports from within Skia 19 import column 20 import imagediffdb 21 from py.utils import gs_utils 22 23 # Keys used within dictionary representation of ImagePairSet. 24 # NOTE: Keep these in sync with static/constants.js 25 KEY__ROOT__EXTRACOLUMNHEADERS = 'extraColumnHeaders' 26 KEY__ROOT__EXTRACOLUMNORDER = 'extraColumnOrder' 27 KEY__ROOT__HEADER = 'header' 28 KEY__ROOT__IMAGEPAIRS = 'imagePairs' 29 KEY__ROOT__IMAGESETS = 'imageSets' 30 KEY__IMAGESETS__FIELD__BASE_URL = 'baseUrl' 31 KEY__IMAGESETS__FIELD__DESCRIPTION = 'description' 32 KEY__IMAGESETS__SET__DIFFS = 'diffs' 33 KEY__IMAGESETS__SET__IMAGE_A = 'imageA' 34 KEY__IMAGESETS__SET__IMAGE_B = 'imageB' 35 KEY__IMAGESETS__SET__WHITEDIFFS = 'whiteDiffs' 36 37 DEFAULT_DESCRIPTIONS = ('setA', 'setB') 38 39 40 class ImagePairSet(object): 41 """A collection of ImagePairs, representing two arbitrary sets of images. 42 43 These could be: 44 - images generated before and after a code patch 45 - expected and actual images for some tests 46 - or any other pairwise set of images. 47 """ 48 49 def __init__(self, diff_base_url, descriptions=None): 50 """ 51 Args: 52 diff_base_url: base URL indicating where diff images can be loaded from 53 descriptions: a (string, string) tuple describing the two image sets. 54 If not specified, DEFAULT_DESCRIPTIONS will be used. 55 """ 56 self._column_header_factories = {} 57 self._descriptions = descriptions or DEFAULT_DESCRIPTIONS 58 self._extra_column_tallies = {} # maps column_id -> values 59 # -> instances_per_value 60 self._imageA_base_url = None 61 self._imageB_base_url = None 62 self._diff_base_url = diff_base_url 63 64 # We build self._image_pair_objects incrementally as calls come into 65 # add_image_pair(); self._image_pair_dicts is filled in lazily (so that 66 # we put off asking ImageDiffDB for results as long as possible). 67 self._image_pair_objects = [] 68 self._image_pair_dicts = None 69 70 def add_image_pair(self, image_pair): 71 """Adds an ImagePair; this may be repeated any number of times.""" 72 # Special handling when we add the first ImagePair... 73 if not self._image_pair_objects: 74 self._imageA_base_url = image_pair.imageA_base_url 75 self._imageB_base_url = image_pair.imageB_base_url 76 77 if(image_pair.imageA_base_url != self._imageA_base_url): 78 raise Exception('added ImagePair with base_url "%s" instead of "%s"' % ( 79 image_pair.imageA_base_url, self._imageA_base_url)) 80 if(image_pair.imageB_base_url != self._imageB_base_url): 81 raise Exception('added ImagePair with base_url "%s" instead of "%s"' % ( 82 image_pair.imageB_base_url, self._imageB_base_url)) 83 self._image_pair_objects.append(image_pair) 84 extra_columns_dict = image_pair.extra_columns_dict 85 if extra_columns_dict: 86 for column_id, value in extra_columns_dict.iteritems(): 87 self._add_extra_column_value_to_summary(column_id, value) 88 89 def set_column_header_factory(self, column_id, column_header_factory): 90 """Overrides the default settings for one of the extraColumn headers. 91 92 Args: 93 column_id: string; unique ID of this column (must match a key within 94 an ImagePair's extra_columns dictionary) 95 column_header_factory: a ColumnHeaderFactory object 96 """ 97 self._column_header_factories[column_id] = column_header_factory 98 99 def get_column_header_factory(self, column_id): 100 """Returns the ColumnHeaderFactory object for a particular extraColumn. 101 102 Args: 103 column_id: string; unique ID of this column (must match a key within 104 an ImagePair's extra_columns dictionary) 105 """ 106 column_header_factory = self._column_header_factories.get(column_id, None) 107 if not column_header_factory: 108 column_header_factory = column.ColumnHeaderFactory(header_text=column_id) 109 self._column_header_factories[column_id] = column_header_factory 110 return column_header_factory 111 112 def ensure_extra_column_values_in_summary(self, column_id, values): 113 """Ensure this column_id/value pair is part of the extraColumns summary. 114 115 Args: 116 column_id: string; unique ID of this column 117 value: string; a possible value for this column 118 """ 119 for value in values: 120 self._add_extra_column_value_to_summary( 121 column_id=column_id, value=value, addend=0) 122 123 def _add_extra_column_value_to_summary(self, column_id, value, addend=1): 124 """Records one column_id/value extraColumns pair found within an ImagePair. 125 126 We use this information to generate tallies within the column header 127 (how many instances we saw of a particular value, within a particular 128 extraColumn). 129 130 Args: 131 column_id: string; unique ID of this column (must match a key within 132 an ImagePair's extra_columns dictionary) 133 value: string; a possible value for this column 134 addend: integer; how many instances to add to the tally 135 """ 136 known_values_for_column = self._extra_column_tallies.get(column_id, None) 137 if not known_values_for_column: 138 known_values_for_column = {} 139 self._extra_column_tallies[column_id] = known_values_for_column 140 instances_of_this_value = known_values_for_column.get(value, 0) 141 instances_of_this_value += addend 142 known_values_for_column[value] = instances_of_this_value 143 144 def _column_headers_as_dict(self): 145 """Returns all column headers as a dictionary.""" 146 asdict = {} 147 for column_id, values_for_column in self._extra_column_tallies.iteritems(): 148 column_header_factory = self.get_column_header_factory(column_id) 149 asdict[column_id] = column_header_factory.create_as_dict( 150 values_for_column) 151 return asdict 152 153 def as_dict(self, column_ids_in_order=None): 154 """Returns a dictionary describing this package of ImagePairs. 155 156 Uses the KEY__* constants as keys. 157 158 Args: 159 column_ids_in_order: A list of all extracolumn IDs in the desired display 160 order. If unspecified, they will be displayed in alphabetical order. 161 If specified, this list must contain all the extracolumn IDs! 162 (It may contain extra column IDs; they will be ignored.) 163 """ 164 all_column_ids = set(self._extra_column_tallies.keys()) 165 if column_ids_in_order == None: 166 column_ids_in_order = sorted(all_column_ids) 167 else: 168 # Make sure the caller listed all column IDs, and throw away any extras. 169 specified_column_ids = set(column_ids_in_order) 170 forgotten_column_ids = all_column_ids - specified_column_ids 171 assert not forgotten_column_ids, ( 172 'column_ids_in_order %s missing these column_ids: %s' % ( 173 column_ids_in_order, forgotten_column_ids)) 174 column_ids_in_order = [c for c in column_ids_in_order 175 if c in all_column_ids] 176 177 key_description = KEY__IMAGESETS__FIELD__DESCRIPTION 178 key_base_url = KEY__IMAGESETS__FIELD__BASE_URL 179 if gs_utils.GSUtils.is_gs_url(self._imageA_base_url): 180 valueA_base_url = self._convert_gs_url_to_http_url(self._imageA_base_url) 181 else: 182 valueA_base_url = self._imageA_base_url 183 if gs_utils.GSUtils.is_gs_url(self._imageB_base_url): 184 valueB_base_url = self._convert_gs_url_to_http_url(self._imageB_base_url) 185 else: 186 valueB_base_url = self._imageB_base_url 187 188 # We've waited as long as we can to ask ImageDiffDB for details of the 189 # image diffs, so that it has time to compute them. 190 if self._image_pair_dicts == None: 191 self._image_pair_dicts = [ip.as_dict() for ip in self._image_pair_objects] 192 193 return { 194 KEY__ROOT__EXTRACOLUMNHEADERS: self._column_headers_as_dict(), 195 KEY__ROOT__EXTRACOLUMNORDER: column_ids_in_order, 196 KEY__ROOT__IMAGEPAIRS: self._image_pair_dicts, 197 KEY__ROOT__IMAGESETS: { 198 KEY__IMAGESETS__SET__IMAGE_A: { 199 key_description: self._descriptions[0], 200 key_base_url: valueA_base_url, 201 }, 202 KEY__IMAGESETS__SET__IMAGE_B: { 203 key_description: self._descriptions[1], 204 key_base_url: valueB_base_url, 205 }, 206 KEY__IMAGESETS__SET__DIFFS: { 207 key_description: 'color difference per channel', 208 key_base_url: posixpath.join( 209 self._diff_base_url, imagediffdb.RGBDIFFS_SUBDIR), 210 }, 211 KEY__IMAGESETS__SET__WHITEDIFFS: { 212 key_description: 'differing pixels in white', 213 key_base_url: posixpath.join( 214 self._diff_base_url, imagediffdb.WHITEDIFFS_SUBDIR), 215 }, 216 }, 217 } 218 219 @staticmethod 220 def _convert_gs_url_to_http_url(gs_url): 221 """Returns HTTP URL that can be used to download this Google Storage file. 222 223 TODO(epoger): Create functionality like this within gs_utils.py instead of 224 here? See https://codereview.chromium.org/428493005/ ('create 225 anyfile_utils.py for copying files between HTTP/GS/local filesystem') 226 227 Args: 228 gs_url: "gs://bucket/path" format URL 229 """ 230 bucket, path = gs_utils.GSUtils.split_gs_url(gs_url) 231 http_url = 'http://storage.cloud.google.com/' + bucket 232 if path: 233 http_url += '/' + path 234 return http_url 235