1# Copyright 2014 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Classes to do screen comparison."""
6
7import logging
8import os
9import time
10
11from PIL import ImageChops
12
13
14class ScreenComparer(object):
15    """A class to compare two screens.
16
17    Calling its member method compare() does the comparison.
18
19    """
20
21    def __init__(self, capturer1, capturer2, output_dir, pixel_diff_margin,
22                 wrong_pixels_margin):
23        """Initializes the ScreenComparer objects.
24
25        @param capture1: The screen capturer object.
26        @param capture2: The screen capturer object.
27        @param output_dir: The directory for output images.
28        @param pixel_diff_margin: The margin for comparing a pixel. Only
29                if a pixel difference exceeds this margin, will treat as a wrong
30                pixel. Sets None means using default value by detecting
31                connector type.
32        @param wrong_pixels_margin: The percentage of margin for wrong pixels.
33                The value is in a closed interval [0.0, 1.0]. If the total
34                number of wrong pixels exceeds this margin, the check fails.
35        """
36        # TODO(waihong): Support multiple capturers.
37        self._capturer1 = capturer1
38        self._capturer2 = capturer2
39        self._output_dir = output_dir
40        self._pixel_diff_margin = pixel_diff_margin
41        assert 0.0 <= wrong_pixels_margin <= 1.0
42        self._wrong_pixels_margin = wrong_pixels_margin
43
44
45    def compare(self):
46        """Compares the screens.
47
48        @return: None if the check passes; otherwise, a string of error message.
49        """
50        tags = [self._capturer1.TAG, self._capturer2.TAG]
51        images = [self._capturer1.capture(), self._capturer2.capture()]
52
53        if None in images:
54            message = ('Failed to capture the screen of %s.' %
55                       tags[images.index(None)])
56            logging.error(message)
57            return message
58
59        # Sometimes the format of images got from X is not RGB,
60        # which may lead to ValueError raised by ImageChops.difference().
61        # So here we check the format before comparing them.
62        for i, image in enumerate(images):
63          if image.mode != 'RGB':
64            images[i] = image.convert('RGB')
65
66        message = 'Unexpected exception'
67        time_str = time.strftime('%H%M%S')
68        try:
69            # The size property is the resolution of the image.
70            if images[0].size != images[1].size:
71                message = ('Sizes of images %s and %s do not match: '
72                           '%dx%d != %dx%d' %
73                           (tuple(tags) + images[0].size + images[1].size))
74                logging.error(message)
75                return message
76
77            size = images[0].size[0] * images[0].size[1]
78            max_acceptable_wrong_pixels = int(self._wrong_pixels_margin * size)
79
80            logging.info('Comparing the images between %s and %s...', *tags)
81            diff_image = ImageChops.difference(*images)
82            histogram = diff_image.convert('L').histogram()
83
84            num_wrong_pixels = sum(histogram[self._pixel_diff_margin + 1:])
85            max_diff_value = max(filter(
86                    lambda x: histogram[x], xrange(len(histogram))))
87            if num_wrong_pixels > 0:
88                logging.debug('Histogram of difference: %r', histogram)
89                prefix_str = '%s-%dx%d' % ((time_str,) + images[0].size)
90                message = ('Result of %s: total %d wrong pixels '
91                           '(diff up to %d)' % (
92                           prefix_str, num_wrong_pixels, max_diff_value))
93                if num_wrong_pixels > max_acceptable_wrong_pixels:
94                    logging.error(message)
95                    return message
96
97                message += (', within the acceptable range %d' %
98                            max_acceptable_wrong_pixels)
99                logging.warning(message)
100            else:
101                logging.info('Result: all pixels match (within +/- %d)',
102                             max_diff_value)
103            message = None
104            return None
105        finally:
106            if message is not None:
107                for i in (0, 1):
108                    # Use time and image size as the filename prefix.
109                    prefix_str = '%s-%dx%d' % ((time_str,) + images[i].size)
110                    # TODO(waihong): Save to a better lossless format.
111                    file_path = os.path.join(
112                            self._output_dir,
113                            '%s-%s.png' % (prefix_str, tags[i]))
114                    logging.info('Output the image %d to %s', i, file_path)
115                    images[i].save(file_path)
116
117                file_path = os.path.join(
118                        self._output_dir, '%s-diff.png' % prefix_str)
119                logging.info('Output the diff image to %s', file_path)
120                diff_image = ImageChops.difference(*images)
121                gray_image = diff_image.convert('L')
122                bw_image = gray_image.point(
123                        lambda x: 0 if x <= self._pixel_diff_margin else 255,
124                        '1')
125                bw_image.save(file_path)
126