1#!/usr/bin/python
2
3"""
4Copyright 2014 Google Inc.
5
6Use of this source code is governed by a BSD-style license that can be
7found in the LICENSE file.
8
9ImagePairSet class; see its docstring below.
10"""
11
12# System-level imports
13import posixpath
14
15# Must fix up PYTHONPATH before importing from within Skia
16import rs_fixpypath  # pylint: disable=W0611
17
18# Imports from within Skia
19import column
20import imagediffdb
21from py.utils import gs_utils
22
23# Keys used within dictionary representation of ImagePairSet.
24# NOTE: Keep these in sync with static/constants.js
25KEY__ROOT__EXTRACOLUMNHEADERS = 'extraColumnHeaders'
26KEY__ROOT__EXTRACOLUMNORDER = 'extraColumnOrder'
27KEY__ROOT__HEADER = 'header'
28KEY__ROOT__IMAGEPAIRS = 'imagePairs'
29KEY__ROOT__IMAGESETS = 'imageSets'
30KEY__IMAGESETS__FIELD__BASE_URL = 'baseUrl'
31KEY__IMAGESETS__FIELD__DESCRIPTION = 'description'
32KEY__IMAGESETS__SET__DIFFS = 'diffs'
33KEY__IMAGESETS__SET__IMAGE_A = 'imageA'
34KEY__IMAGESETS__SET__IMAGE_B = 'imageB'
35KEY__IMAGESETS__SET__WHITEDIFFS = 'whiteDiffs'
36
37DEFAULT_DESCRIPTIONS = ('setA', 'setB')
38
39
40class 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