Home | History | Annotate | Download | only in image_processing
      1 #!/usr/bin/env python
      2 # Copyright 2014 The Chromium Authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 #
      6 # This script attempts to detect the region of a camera's field of view that
      7 # contains the screen of the device we are testing.
      8 #
      9 # Usage: ./screen_finder.py path_to_video 0 0 --verbose
     10 
     11 from __future__ import division
     12 
     13 import copy
     14 import logging
     15 import os
     16 import sys
     17 
     18 if __name__ == '__main__':
     19   sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..'))
     20 
     21 from telemetry.internal.image_processing import cv_util
     22 from telemetry.internal.image_processing import frame_generator as \
     23     frame_generator_module
     24 from telemetry.internal.image_processing import video_file_frame_generator
     25 from telemetry.internal.util import external_modules
     26 
     27 np = external_modules.ImportRequiredModule('numpy')
     28 cv2 = external_modules.ImportRequiredModule('cv2')
     29 
     30 
     31 class ScreenFinder(object):
     32   """Finds and extracts device screens from video.
     33 
     34   Sample Usage:
     35     sf = ScreenFinder(sys.argv[1])
     36     while sf.HasNext():
     37       ret, screen = sf.GetNext()
     38 
     39   Attributes:
     40     _lost_corners: Each index represents whether or not we lost track of that
     41         corner on the previous frame. Ordered by [top-right, top-left,
     42         bottom-left, bottom-right]
     43     _frame: An unmodified copy of the frame we're currently processing.
     44     _frame_debug: A copy of the frame we're currently processing, may be
     45         modified at any time, used for debugging.
     46     _frame_grey: A greyscale copy of the frame we're currently processing.
     47     _frame_edges: A Canny Edge detected copy of the frame we're currently
     48         processing.
     49     _screen_size: The size of device screen in the video when first detected.
     50     _avg_corners: Exponentially weighted average of the previous corner
     51         locations.
     52     _prev_corners: The location of the corners in the previous frame.
     53     _lost_corner_frames: A counter of the number of successive frames in which
     54         we've lost a corner location.
     55     _border: See |border| above.
     56     _min_line_length: The minimum length a line must be before we consider it
     57         a possible screen edge.
     58     _frame_generator: See |frame_generator| above.
     59     _width, _height: The width and height of the frame.
     60     _anglesp5, _anglesm5: The angles for each point we look at in the grid
     61         when computing brightness, constant across frames."""
     62 
     63   class ScreenNotFoundError(Exception):
     64     pass
     65 
     66   # Square of the distance a corner can travel in pixels between frames
     67   MAX_INTERFRAME_MOTION = 25
     68   # The minimum width line that may be considered a screen edge.
     69   MIN_SCREEN_WIDTH = 40
     70   # Number of frames with lost corners before we ignore MAX_INTERFRAME_MOTION
     71   RESET_AFTER_N_BAD_FRAMES = 2
     72   # The weight applied to the new screen location when exponentially averaging
     73   # screen location.
     74   # TODO(mthiesse): This should be framerate dependent, for lower framerates
     75   # this value should approach 1. For higher framerates, this value should
     76   # approach 0. The current 0.5 value works well in testing with 240 FPS.
     77   CORNER_AVERAGE_WEIGHT = 0.5
     78 
     79   # TODO(mthiesse): Investigate how to select the constants used here. In very
     80   # bright videos, twice as bright may be too high, and the minimum of 60 may
     81   # be too low.
     82   # The factor by which a quadrant at an intersection must be brighter than
     83   # the other quadrants to be considered a screen corner.
     84   MIN_RELATIVE_BRIGHTNESS_FACTOR = 1.5
     85   # The minimum average brightness required of an intersection quadrant to
     86   # be considered a screen corner (on a scale of 0-255).
     87   MIN_CORNER_ABSOLUTE_BRIGHTNESS = 60
     88 
     89   # Low and high hysteresis parameters to be passed to the Canny edge
     90   # detection algorithm.
     91   CANNY_HYSTERESIS_THRESH_LOW = 300
     92   CANNY_HYSTERESIS_THRESH_HIGH = 500
     93 
     94   SMALL_ANGLE = 5 / 180 * np.pi  # 5 degrees in radians
     95 
     96   DEBUG = False
     97 
     98   def __init__(self, frame_generator, border=5):
     99     """Initializes the ScreenFinder object.
    100 
    101     Args:
    102       frame_generator: FrameGenerator, An initialized Video Frame Generator.
    103       border: int, number of pixels of border to be kept when cropping the
    104           detected screen.
    105 
    106     Raises:
    107       FrameReadError: The frame generator may output a read error during
    108           initialization."""
    109     assert isinstance(frame_generator, frame_generator_module.FrameGenerator)
    110     self._lost_corners = [False, False, False, False]
    111     self._frame_debug = None
    112     self._frame = None
    113     self._frame_grey = None
    114     self._frame_edges = None
    115     self._screen_size = None
    116     self._avg_corners = None
    117     self._prev_corners = None
    118     self._lost_corner_frames = 0
    119     self._border = border
    120     self._min_line_length = self.MIN_SCREEN_WIDTH
    121     self._frame_generator = frame_generator
    122     self._anglesp5 = None
    123     self._anglesm5 = None
    124 
    125     if not self._InitNextFrame():
    126       logging.warn('Not enough frames in video feed!')
    127       return
    128 
    129     self._height, self._width = self._frame.shape[:2]
    130 
    131   def _InitNextFrame(self):
    132     """Called after processing each frame, reads in the next frame to ensure
    133     HasNext() is accurate."""
    134     self._frame_debug = None
    135     self._frame = None
    136     self._frame_grey = None
    137     self._frame_edges = None
    138     try:
    139       frame = next(self._frame_generator.Generator)
    140     except StopIteration:
    141       return False
    142     self._frame = frame
    143     self._frame_debug = copy.copy(frame)
    144     self._frame_grey = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    145     self._frame_edges = cv2.Canny(self._frame_grey,
    146                                   self.CANNY_HYSTERESIS_THRESH_LOW,
    147                                   self.CANNY_HYSTERESIS_THRESH_HIGH)
    148     return True
    149 
    150   def HasNext(self):
    151     """True if there are more frames available to process. """
    152     return self._frame is not None
    153 
    154   def GetNext(self):
    155     """Gets the next screen image.
    156 
    157     Returns:
    158       A numpy matrix containing the screen surrounded by the number of border
    159       pixels specified in initialization, and the location of the detected
    160       screen corners in the current frame, if a screen is found. The returned
    161       screen is guaranteed to be the same size at each frame.
    162       'None' and 'None' if no screen was found on the current frame.
    163 
    164     Raises:
    165       FrameReadError: An error occurred in the FrameGenerator.
    166       RuntimeError: This method was called when no frames were available."""
    167     if self._frame is None:
    168       raise RuntimeError('No more frames available.')
    169 
    170     logging.info('Processing frame: %d',
    171                  self._frame_generator.CurrentFrameNumber)
    172 
    173     # Finds straight lines in the image.
    174     hlines = cv2.HoughLinesP(self._frame_edges, 1, np.pi / 180, 60,
    175                              minLineLength=self._min_line_length,
    176                              maxLineGap=100)
    177 
    178     # Extends these straight lines to be long enough to ensure the screen edge
    179     # lines intersect.
    180     lines = cv_util.ExtendLines(np.float32(hlines[0]), 10000) \
    181         if hlines is not None else []
    182 
    183     # Find intersections in the lines; these are likely to be screen corners.
    184     intersections = self._FindIntersections(lines)
    185     if len(intersections[:, 0]) > 0:
    186       points = np.vstack(intersections[:, 0].flat)
    187       if (self._prev_corners is not None and len(points) >= 4 and
    188           not self._HasMovedFast(points, self._prev_corners)):
    189         corners = self._prev_corners
    190         missing_corners = 0
    191       else:
    192         # Extract the corners from all intersections.
    193         corners, missing_corners = self._FindCorners(
    194             intersections, self._frame_grey)
    195     else:
    196       corners = np.empty((4, 2), np.float32)
    197       corners[:] = np.nan
    198       missing_corners = 4
    199 
    200     screen = None
    201     found_screen = True
    202     final_corners = None
    203     try:
    204       # Handle the cases where we have missing corners.
    205       screen_corners = self._NewScreenLocation(
    206           corners, missing_corners, intersections)
    207 
    208       final_corners = self._SmoothCorners(screen_corners)
    209 
    210       # Create a perspective transform from our corners.
    211       transform, w, h = self._GetTransform(final_corners, self._border)
    212 
    213       # Apply the perspective transform to get our output.
    214       screen = cv2.warpPerspective(
    215           self._frame, transform, (int(w + 0.5), int(h + 0.5)))
    216 
    217       self._prev_corners = final_corners
    218 
    219     except self.ScreenNotFoundError as e:
    220       found_screen = False
    221       logging.info(e)
    222 
    223     if self.DEBUG:
    224       self._Debug(lines, corners, final_corners, screen)
    225 
    226     self._InitNextFrame()
    227     if found_screen:
    228       return screen, self._prev_corners
    229     return None, None
    230 
    231   def _FindIntersections(self, lines):
    232     """Finds intersections in a set of lines.
    233 
    234     Filters pairs of lines that are less than 45 degrees apart. Filtering
    235     these pairs helps dramatically reduce the number of points we have to
    236     process, as these points could not represent screen corners anyways.
    237 
    238     Returns:
    239       The intersections, represented as a tuple of (point, line, line) of the
    240       points and the lines that intersect there of all lines in the array that
    241       are more than 45 degrees apart."""
    242     intersections = np.empty((0, 3), np.float32)
    243     for i in xrange(0, len(lines)):
    244       for j in xrange(i + 1, len(lines)):
    245         # Filter lines that are less than 45 (or greater than 135) degrees
    246         # apart.
    247         if not cv_util.AreLinesOrthogonal(lines[i], lines[j], (np.pi / 4.0)):
    248           continue
    249         ret, point = cv_util.FindLineIntersection(lines[i], lines[j])
    250         point = np.float32(point)
    251         if not ret:
    252           continue
    253         # If we know where the previous corners are, we can also filter
    254         # intersections that are too far away from the previous corners to be
    255         # where the screen has moved.
    256         if self._prev_corners is not None and \
    257            self._lost_corner_frames <= self.RESET_AFTER_N_BAD_FRAMES and \
    258            not self._PointIsCloseToPreviousCorners(point):
    259           continue
    260         intersections = np.vstack((intersections,
    261                                    np.array((point, lines[i], lines[j]))))
    262     return intersections
    263 
    264   def _PointIsCloseToPreviousCorners(self, point):
    265     """True if the point is close to the previous corners."""
    266     max_dist = self.MAX_INTERFRAME_MOTION
    267     if cv_util.SqDistance(self._prev_corners[0], point) <= max_dist or \
    268        cv_util.SqDistance(self._prev_corners[1], point) <= max_dist or \
    269        cv_util.SqDistance(self._prev_corners[2], point) <= max_dist or \
    270        cv_util.SqDistance(self._prev_corners[3], point) <= max_dist:
    271       return True
    272     return False
    273 
    274   def _HasMovedFast(self, corners, prev_corners):
    275     min_dist = np.zeros(4, np.float32)
    276     for i in xrange(4):
    277       dist = np.min(cv_util.SqDistances(corners, prev_corners[i]))
    278       min_dist[i] = dist
    279     # 3 corners can move up to one pixel before we consider the screen to have
    280     # moved. TODO(mthiesse): Should this be relaxed? Resolution dependent?
    281     if np.sum(min_dist) < 3:
    282       return False
    283     return True
    284 
    285   class CornerData(object):
    286 
    287     def __init__(self, corner_index, corner_location, brightness_score, line1,
    288                  line2):
    289       self.corner_index = corner_index
    290       self.corner_location = corner_location
    291       self.brightness_score = brightness_score
    292       self.line1 = line1
    293       self.line2 = line2
    294 
    295     def __gt__(self, corner_data2):
    296       return self.corner_index > corner_data2.corner_index
    297 
    298     def __repr__(self):
    299       return ('\nCorner index: ' + str(self.corner_index) +
    300               ',\nCorner location: ' + str(self.corner_location) +
    301               ',\nBrightness score: ' + str(self.brightness_score) +
    302               ',\nline1: ' + str(self.line1) + ',\nline2: ' + str(self.line2))
    303 
    304   def _FindCorners(self, intersections, grey_frame):
    305     """Finds the screen corners in the image.
    306 
    307     Given the set of intersections in the image, finds the intersections most
    308     likely to be corners.
    309 
    310     Args:
    311       intersections: The array of intersections in the image.
    312       grey_frame: The greyscale frame we're processing.
    313 
    314     Returns:
    315       An array of length 4 containing the positions of the corners, or nan for
    316       each index where a corner could not be found, and a count of the number
    317       of missing corners.
    318       The corners are ordered as follows:
    319         1 | 0
    320         -----
    321         2 | 3
    322       Ex. 3 corners are found from a square of width 2 centered at the origin,
    323       the output would look like:
    324           '[[1, 1], [np.nan, np.nan], [-1, -1], [1, -1]], 1'"""
    325     filtered = []
    326     corners = np.empty((0, 2), np.float32)
    327     for corner_pos, score, point, line1, line2 in \
    328         self._LooksLikeCorner(intersections, grey_frame):
    329       if self.DEBUG:
    330         center = (int(point[0] + 0.5), int(point[1] + 0.5))
    331         cv2.circle(self._frame_debug, center, 5, (0, 255, 0), 1)
    332       point.resize(1, 2)
    333       corners = np.append(corners, point, axis=0)
    334       point.resize(2,)
    335       corner_data = self.CornerData(corner_pos, point, score, line1, line2)
    336       filtered.append(corner_data)
    337 
    338     # De-duplicate corners because we may have found many false positives, or
    339     # near-misses.
    340     self._DeDupCorners(filtered, corners)
    341 
    342     # Strip everything but the corner location.
    343     filtered_corners = np.array(
    344         [corner_data.corner_location for corner_data in filtered])
    345     corner_indices = [corner_data.corner_index for corner_data in filtered]
    346 
    347     # If we have found a corner to replace a lost corner, we want to check
    348     # that the corner is not erroneous by ensuring it makes a rectangle with
    349     # the 3 known good corners.
    350     if len(filtered) == 4:
    351       for i in xrange(4):
    352         point_info = (filtered[i].corner_location,
    353                       filtered[i].line1,
    354                       filtered[i].line2)
    355         if (self._lost_corners[i] and
    356             not self._PointConnectsToCorners(filtered_corners, point_info)):
    357           filtered_corners = np.delete(filtered_corners, i, 0)
    358           corner_indices = np.delete(corner_indices, i, 0)
    359           break
    360 
    361     # Ensure corners are sorted properly, inserting nans for missing corners.
    362     sorted_corners = np.empty((4, 2), np.float32)
    363     sorted_corners[:] = np.nan
    364     for i in xrange(len(filtered_corners)):
    365       sorted_corners[corner_indices[i]] = filtered_corners[i]
    366 
    367     # From this point on, our corners arrays are guaranteed to have 4
    368     # elements, though some may be nan.
    369 
    370     # Filter corners that have moved too far from the previous corner if we
    371     # are not resetting known corner information.
    372     reset_corners = (
    373         (self._lost_corner_frames > self.RESET_AFTER_N_BAD_FRAMES)
    374         and len(filtered_corners) == 4)
    375     if self._prev_corners is not None and not reset_corners:
    376       sqdists = cv_util.SqDistances(self._prev_corners, sorted_corners)
    377       for i in xrange(4):
    378         if np.isnan(sorted_corners[i][0]):
    379           continue
    380         if sqdists[i] > self.MAX_INTERFRAME_MOTION:
    381           sorted_corners[i] = np.nan
    382 
    383     real_corners = self._FindExactCorners(sorted_corners)
    384     missing_corners = np.count_nonzero(np.isnan(real_corners)) / 2
    385     return real_corners, missing_corners
    386 
    387   def _LooksLikeCorner(self, intersections, grey_frame):
    388     """Finds any intersections of lines that look like a screen corner.
    389 
    390     Args:
    391       intersections: The numpy array of points, and the lines that intersect
    392           at the given point.
    393       grey_frame: The greyscale frame we're processing.
    394 
    395     Returns:
    396       An array of: The corner location (0-3), the relative brightness score
    397       (to be used to de-duplicate corners later), the point, and the lines
    398       that make up the intersection, for all intersections that look like a
    399       corner."""
    400     points = np.vstack(intersections[:, 0].flat)
    401     lines1 = np.vstack(intersections[:, 1].flat)
    402     lines2 = np.vstack(intersections[:, 2].flat)
    403     # Map the image to four quadrants defined as the regions between each of
    404     # the lines that make up the intersection.
    405     line1a1 = np.pi - np.arctan2(lines1[:, 1] - points[:, 1],
    406                                  lines1[:, 0] - points[:, 0])
    407     line1a2 = np.pi - np.arctan2(lines1[:, 3] - points[:, 1],
    408                                  lines1[:, 2] - points[:, 0])
    409     line2a1 = np.pi - np.arctan2(lines2[:, 1] - points[:, 1],
    410                                  lines2[:, 0] - points[:, 0])
    411     line2a2 = np.pi - np.arctan2(lines2[:, 3] - points[:, 1],
    412                                  lines2[:, 2] - points[:, 0])
    413     line1a1 = line1a1.reshape(-1, 1)
    414     line1a2 = line1a2.reshape(-1, 1)
    415     line2a1 = line2a1.reshape(-1, 1)
    416     line2a2 = line2a2.reshape(-1, 1)
    417 
    418     line_angles = np.concatenate((line1a1, line1a2, line2a1, line2a2), axis=1)
    419     np.ndarray.sort(line_angles)
    420 
    421     # TODO(mthiesse): Investigate whether these should scale with image or
    422     # screen size. My intuition is that these don't scale with image size,
    423     # though they may be affected by image quality and how blurry the corners
    424     # are. See stackoverflow.com/q/7765810/ for inspiration.
    425     avg_range = 8.0
    426     num_points = 7
    427 
    428     points_m_avg = points - avg_range
    429     points_p_avg = points + avg_range
    430     # Exclude points near frame boundaries.
    431     include = np.where((points_m_avg[:, 0] > 0) & (points_m_avg[:, 1] > 0) &
    432                        (points_p_avg[:, 0] < self._width) &
    433                        (points_p_avg[:, 1] < self._height))
    434     line_angles = line_angles[include]
    435     points = points[include]
    436     lines1 = lines1[include]
    437     lines2 = lines2[include]
    438     points_m_avg = points_m_avg[include]
    439     points_p_avg = points_p_avg[include]
    440     # Perform a 2-d linspace to generate the x, y ranges for each
    441     # intersection.
    442     arr1 = points_m_avg[:, 0].reshape(-1, 1)
    443     arr2 = points_p_avg[:, 0].reshape(-1, 1)
    444     lin = np.linspace(0, 1, num_points)
    445     x_range = arr1 + (arr2 - arr1) * lin
    446     arr1 = points_m_avg[:, 1].reshape(-1, 1)
    447     arr2 = points_p_avg[:, 1].reshape(-1, 1)
    448     y_range = arr1 + (arr2 - arr1) * lin
    449 
    450     # The angles for each point we look at in the grid when computing
    451     # brightness are constant across frames, so we can generate them once.
    452     if self._anglesp5 is None:
    453       ind = np.transpose([np.tile(x_range[0], num_points),
    454                           np.repeat(y_range[0], num_points)])
    455       vectors = ind - points[0]
    456       angles = np.arctan2(vectors[:, 1], vectors[:, 0]) + np.pi
    457       self._anglesp5 = angles + self.SMALL_ANGLE
    458       self._anglesm5 = angles - self.SMALL_ANGLE
    459     results = []
    460     for i in xrange(len(y_range)):
    461       # Generate our filters for which points belong to which quadrant.
    462       one = np.where((self._anglesp5 <= line_angles[i, 1]) &
    463                      (self._anglesm5 >= line_angles[i, 0]))
    464       two = np.where((self._anglesp5 <= line_angles[i, 2]) &
    465                      (self._anglesm5 >= line_angles[i, 1]))
    466       thr = np.where((self._anglesp5 <= line_angles[i, 3]) &
    467                      (self._anglesm5 >= line_angles[i, 2]))
    468       fou = np.where((self._anglesp5 <= line_angles[i, 0]) |
    469                      (self._anglesm5 >= line_angles[i, 3]))
    470       # Take the cartesian product of our x and y ranges to get the full list
    471       # of pixels to look at.
    472       ind = np.transpose([np.tile(x_range[i], num_points),
    473                           np.repeat(y_range[i], num_points)])
    474 
    475       # Filter the full list by which indices belong to which quadrant, and
    476       # convert to integers so we can index with them.
    477       one_i = np.int32(np.rint(ind[one[0]]))
    478       two_i = np.int32(np.rint(ind[two[0]]))
    479       thr_i = np.int32(np.rint(ind[thr[0]]))
    480       fou_i = np.int32(np.rint(ind[fou[0]]))
    481 
    482       # Average the brightness of the pixels that belong to each quadrant.
    483       q_1 = np.average(grey_frame[one_i[:, 1], one_i[:, 0]])
    484       q_2 = np.average(grey_frame[two_i[:, 1], two_i[:, 0]])
    485       q_3 = np.average(grey_frame[thr_i[:, 1], thr_i[:, 0]])
    486       q_4 = np.average(grey_frame[fou_i[:, 1], fou_i[:, 0]])
    487 
    488       avg_intensity = [(q_4, 0), (q_1, 1), (q_2, 2), (q_3, 3)]
    489       # Sort by intensity.
    490       avg_intensity.sort(reverse=True)
    491 
    492       # Treat the point as a corner if one quadrant is at least twice as
    493       # bright as the next brightest quadrant, with a minimum brightness
    494       # requirement.
    495       tau = (2.0 * np.pi)
    496       min_factor = self.MIN_RELATIVE_BRIGHTNESS_FACTOR
    497       min_brightness = self.MIN_RELATIVE_BRIGHTNESS_FACTOR
    498       if avg_intensity[0][0] > avg_intensity[1][0] * min_factor and \
    499          avg_intensity[0][0] > min_brightness:
    500         bright_corner = avg_intensity[0][1]
    501         if bright_corner == 0:
    502           angle = np.pi - (line_angles[i, 0] + line_angles[i, 3]) / 2.0
    503           if angle < 0:
    504             angle = angle + tau
    505         else:
    506           angle = tau - (line_angles[i, bright_corner] +
    507                          line_angles[i, bright_corner - 1]) / 2.0
    508         score = avg_intensity[0][0] - avg_intensity[1][0]
    509         # TODO(mthiesse): int(angle / (pi / 2.0)) will break if the screen is
    510         # rotated through 45 degrees. Probably many other things will break as
    511         # well, movement of corners from one quadrant to another hasn't been
    512         # tested. We should support this eventually, but this is unlikely to
    513         # cause issues for any test setups.
    514         results.append((int(angle / (np.pi / 2.0)), score, points[i],
    515                         lines1[i], lines2[i]))
    516     return results
    517 
    518   def _DeDupCorners(self, corner_data, corners):
    519     """De-duplicate corners based on corner_index.
    520 
    521     For each set of points representing a corner: If one point is part of the
    522     rectangle and the other is not, filter the other one. If both or none are
    523     part of the rectangle, filter based on score (highest relative brightness
    524     of a quadrant). The reason we allow for neither to be part of the
    525     rectangle is because we may not have found all four corners of the
    526     rectangle, and in degenerate cases like this it's better to find 3 likely
    527     corners than none.
    528 
    529     Modifies corner_data directly.
    530 
    531     Args:
    532       corner_data: CornerData for each potential corner in the frame.
    533       corners: List of all potential corners in the frame."""
    534     # TODO(mthiesse): Ensure that the corners form a sensible rectangle. For
    535     # example, it is currently possible (but unlikely) to detect a 'screen'
    536     # where the bottom-left corner is above the top-left corner, while the
    537     # bottom-right corner is below the top-right corner.
    538 
    539     # Sort by corner_index to make de-duping easier.
    540     corner_data.sort()
    541 
    542     # De-dup corners.
    543     c_old = None
    544     for i in xrange(len(corner_data) - 1, 0, -1):
    545       if corner_data[i].corner_index != corner_data[i - 1].corner_index:
    546         c_old = None
    547         continue
    548       if c_old is None:
    549         point_info = (corner_data[i].corner_location,
    550                       corner_data[i].line1,
    551                       corner_data[i].line2)
    552         c_old = self._PointConnectsToCorners(corners, point_info, 2)
    553       point_info_new = (corner_data[i - 1].corner_location,
    554                         corner_data[i - 1].line1,
    555                         corner_data[i - 1].line2)
    556       c_new = self._PointConnectsToCorners(corners, point_info_new, 2)
    557       if (not (c_old or c_new)) or (c_old and c_new):
    558         if (corner_data[i].brightness_score <
    559             corner_data[i - 1].brightness_score):
    560           del corner_data[i]
    561           c_old = c_new
    562         else:
    563           del corner_data[i - 1]
    564       elif c_old:
    565         del corner_data[i - 1]
    566       else:
    567         del corner_data[i]
    568         c_old = c_new
    569 
    570   def _PointConnectsToCorners(self, corners, point_info, tolerance=1):
    571     """Checks if the lines of an intersection intersect with corners.
    572 
    573     This is useful to check if the point is part of a rectangle specified by
    574     |corners|.
    575 
    576     Args:
    577       point_info: A tuple of (point, line, line) representing an intersection
    578           of two lines.
    579       corners: corners that (hopefully) make up a rectangle.
    580       tolerance: The tolerance (approximately in pixels) of the distance
    581           between the corners and the lines for detecting if the point is on
    582           the line.
    583 
    584     Returns:
    585       True if each of the two lines that make up the intersection where the
    586       point is located connect the point to other corners."""
    587     line1_connected = False
    588     line2_connected = False
    589     point, line1, line2 = point_info
    590     for corner in corners:
    591       if corner is None:
    592         continue
    593 
    594       # Filter out points that are too close to one another to be different
    595       # corners.
    596       sqdist = cv_util.SqDistance(corner, point)
    597       if sqdist < self.MIN_SCREEN_WIDTH * self.MIN_SCREEN_WIDTH:
    598         continue
    599 
    600       line1_connected = line1_connected or \
    601           cv_util.IsPointApproxOnLine(corner, line1, tolerance)
    602       line2_connected = line2_connected or \
    603           cv_util.IsPointApproxOnLine(corner, line2, tolerance)
    604     if line1_connected and line2_connected:
    605       return True
    606     return False
    607 
    608   def _FindExactCorners(self, sorted_corners):
    609     """Attempts to find more accurate corner locations.
    610 
    611     Args:
    612       sorted_corners: The four screen corners, sorted by corner_index.
    613 
    614     Returns:
    615       A list of 4 probably more accurate corners, still sorted."""
    616     real_corners = np.empty((4, 2), np.float32)
    617     # Count missing corners, and search in a small area around our
    618     # intersections representing corners to see if we can find a more exact
    619     # corner, as the position of the intersections is noisy and not always
    620     # perfectly accurate.
    621     for i in xrange(4):
    622       corner = sorted_corners[i]
    623       if np.isnan(corner[0]):
    624         real_corners[i] = np.nan
    625         continue
    626 
    627       # Almost unbelievably, in edge cases with floating point error, the
    628       # width/height of the cropped corner image may be 2 or 4. This is fine
    629       # though, as long as the width and height of the cropped corner are not
    630       # hard-coded anywhere.
    631       corner_image = self._frame_edges[corner[1] - 1:corner[1] + 2,
    632                                        corner[0] - 1:corner[0] + 2]
    633       ret, p = self._FindExactCorner(i <= 1, i == 1 or i == 2, corner_image)
    634       if ret:
    635         if self.DEBUG:
    636           self._frame_edges[corner[1] - 1 + p[1]][corner[0] - 1 + p[0]] = 128
    637         real_corners[i] = corner - 1 + p
    638       else:
    639         real_corners[i] = corner
    640     return real_corners
    641 
    642   def _FindExactCorner(self, top, left, img):
    643     """Tries to finds the exact corner location for a given corner.
    644 
    645     Searches for the top or bottom, left or right most lit
    646     pixel in an edge-detected image, which should represent, with pixel
    647     precision, as accurate a corner location as possible. (Though perhaps
    648     up-sampling using cubic spline interpolation could get sub-pixel
    649     precision)
    650 
    651     TODO(mthiesse): This algorithm could be improved by including a larger
    652     region to search in, but would have to be made smarter about which lit
    653     pixels are on the detected screen edge and which are a not as it's
    654     currently extremely easy to fool by things like notification icons in
    655     screen corners.
    656 
    657     Args:
    658       top: boolean, whether or not we're looking for a top corner.
    659       left: boolean, whether or not we're looking for a left corner.
    660       img: A small cropping of the edge detected image in which to search.
    661 
    662     Returns:
    663       True and the location if a better corner location is found,
    664       False otherwise."""
    665     h, w = img.shape[:2]
    666     cy = 0
    667     starting_x = w - 1 if left else 0
    668     cx = starting_x
    669     if top:
    670       y_range = xrange(h - 1, -1, -1)
    671     else:
    672       y_range = xrange(0, h, 1)
    673     if left:
    674       x_range = xrange(w - 1, -1, -1)
    675     else:
    676       x_range = xrange(0, w, 1)
    677     for y in y_range:
    678       for x in x_range:
    679         if img[y][x] == 255:
    680           cy = y
    681           if (left and x <= cx) or (not left and x >= cx):
    682             cx = x
    683     if cx == starting_x and cy == 0 and img[0][starting_x] != 255:
    684       return False, (0, 0)
    685     return True, (cx, cy)
    686 
    687   def _NewScreenLocation(self, new_corners, missing_corners, intersections):
    688     """Computes the new screen location with best effort.
    689 
    690     Creates the final list of corners that represents the best effort attempt
    691     to find the new screen location. Handles degenerate cases where 3 or fewer
    692     new corners are present, using previous corner and intersection data.
    693 
    694     Args:
    695       new_corners: The corners found by our search for corners.
    696       missing_corners: The count of how many corners we're missing.
    697       intersections: The intersections of straight lines found in the current
    698           frame.
    699 
    700     Returns:
    701       An array of 4 new_corners hopefully representing the screen, or throws
    702       an error if this is not possible.
    703 
    704     Raises:
    705       ValueError: Finding the screen location was not possible."""
    706     screen_corners = copy.copy(new_corners)
    707     if missing_corners == 0:
    708       self._lost_corner_frames = 0
    709       self._lost_corners = [False, False, False, False]
    710       return screen_corners
    711     if self._prev_corners is None:
    712       raise self.ScreenNotFoundError(
    713           'Could not locate screen on frame %d' %
    714           self._frame_generator.CurrentFrameNumber)
    715 
    716     self._lost_corner_frames += 1
    717     if missing_corners > 1:
    718       logging.info('Unable to properly detect screen corners, making '
    719                    'potentially false assumptions on frame %d',
    720                    self._frame_generator.CurrentFrameNumber)
    721     # Replace missing new_corners with either nearest intersection to previous
    722     # corner, or previous corner if no intersections are found.
    723     for i in xrange(0, 4):
    724       if not np.isnan(new_corners[i][0]):
    725         self._lost_corners[i] = False
    726         continue
    727       self._lost_corners[i] = True
    728       min_dist = self.MAX_INTERFRAME_MOTION
    729       min_corner = None
    730 
    731       for isection in intersections:
    732         dist = cv_util.SqDistance(isection[0], self._prev_corners[i])
    733         if dist >= min_dist:
    734           continue
    735         if missing_corners == 1:
    736           # We know in this case that we have 3 corners present, meaning
    737           # all 4 screen lines, and therefore intersections near screen
    738           # corners present, so our new corner must connect to these
    739           # other corners.
    740           if not self._PointConnectsToCorners(new_corners, isection, 3):
    741             continue
    742         min_corner = isection[0]
    743         min_dist = dist
    744       screen_corners[i] = min_corner if min_corner is not None else \
    745           self._prev_corners[i]
    746 
    747     return screen_corners
    748 
    749   def _SmoothCorners(self, corners):
    750     """Smoothes the motion of corners, reduces noise.
    751 
    752     Smoothes the motion of corners by computing an exponentially weighted
    753     moving average of corner positions over time.
    754 
    755     Args:
    756       corners: The corners of the detected screen.
    757 
    758     Returns:
    759       The final corner positions."""
    760     if self._avg_corners is None:
    761       self._avg_corners = np.asfarray(corners, np.float32)
    762     for i in xrange(0, 4):
    763       # Keep an exponential moving average of the corner location to reduce
    764       # noise.
    765       new_contrib = np.multiply(self.CORNER_AVERAGE_WEIGHT, corners[i])
    766       old_contrib = np.multiply(1 - self.CORNER_AVERAGE_WEIGHT,
    767                                 self._avg_corners[i])
    768       self._avg_corners[i] = np.add(new_contrib, old_contrib)
    769 
    770     return self._avg_corners
    771 
    772   def _GetTransform(self, corners, border):
    773     """Gets the perspective transform of the screen.
    774 
    775     Args:
    776       corners: The corners of the detected screen.
    777       border: The number of pixels of border to crop along with the screen.
    778 
    779     Returns:
    780       A perspective transform and the width and height of the target
    781       transform.
    782 
    783     Raises:
    784       ScreenNotFoundError: Something went wrong in detecting the screen."""
    785     if self._screen_size is None:
    786       w = np.sqrt(cv_util.SqDistance(corners[1], corners[0]))
    787       h = np.sqrt(cv_util.SqDistance(corners[1], corners[2]))
    788       if w < 1 or h < 1:
    789         raise self.ScreenNotFoundError(
    790             'Screen detected improperly (bad corners)')
    791       if min(w, h) < self.MIN_SCREEN_WIDTH:
    792         raise self.ScreenNotFoundError('Detected screen was too small.')
    793 
    794       self._screen_size = (w, h)
    795       # Extend min line length, if we can, to reduce the number of extraneous
    796       # lines the line finder finds.
    797       self._min_line_length = max(self._min_line_length, min(w, h) / 1.75)
    798     w = self._screen_size[0]
    799     h = self._screen_size[1]
    800 
    801     target = np.zeros((4, 2), np.float32)
    802     width = w + border
    803     height = h + border
    804     target[0] = np.asfarray((width, border))
    805     target[1] = np.asfarray((border, border))
    806     target[2] = np.asfarray((border, height))
    807     target[3] = np.asfarray((width, height))
    808     transform_w = width + border
    809     transform_h = height + border
    810     transform = cv2.getPerspectiveTransform(corners, target)
    811     return transform, transform_w, transform_h
    812 
    813   def _Debug(self, lines, corners, final_corners, screen):
    814     for line in lines:
    815       intline = ((int(line[0]), int(line[1])),
    816                  (int(line[2]), int(line[3])))
    817       cv2.line(self._frame_debug, intline[0], intline[1], (0, 0, 255), 1)
    818     i = 0
    819     for corner in corners:
    820       if not np.isnan(corner[0]):
    821         cv2.putText(
    822             self._frame_debug, str(i), (int(corner[0]), int(corner[1])),
    823             cv2.FONT_HERSHEY_COMPLEX_SMALL, 1, (255, 255, 0), 1, cv2.CV_AA)
    824         i += 1
    825     if final_corners is not None:
    826       for corner in final_corners:
    827         cv2.circle(self._frame_debug,
    828                    (int(corner[0]), int(corner[1])), 5, (255, 0, 255), 1)
    829     cv2.imshow('original', self._frame)
    830     cv2.imshow('debug', self._frame_debug)
    831     if screen is not None:
    832       cv2.imshow('screen', screen)
    833     cv2.waitKey()
    834 
    835 # For being run as a script.
    836 # TODO(mthiesse): To be replaced with a better standalone script.
    837 # Ex: ./screen_finder.py path_to_video 0 5 --verbose
    838 
    839 
    840 def main():
    841   start_frame = int(sys.argv[2]) if len(sys.argv) >= 3 else 0
    842   vf = video_file_frame_generator.VideoFileFrameGenerator(sys.argv[1],
    843                                                           start_frame)
    844   if len(sys.argv) >= 4:
    845     sf = ScreenFinder(vf, int(sys.argv[3]))
    846   else:
    847     sf = ScreenFinder(vf)
    848   # TODO(mthiesse): Use argument parser to improve command line parsing.
    849   if len(sys.argv) > 4 and sys.argv[4] == '--verbose':
    850     logging.basicConfig(format='%(message)s', level=logging.INFO)
    851   else:
    852     logging.basicConfig(format='%(message)s', level=logging.WARN)
    853   while sf.HasNext():
    854     sf.GetNext()
    855 
    856 if __name__ == '__main__':
    857   main()
    858