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
11from __future__ import division
12
13import copy
14import logging
15import os
16import sys
17
18if __name__ == '__main__':
19  sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..'))
20
21from telemetry.internal.image_processing import cv_util
22from telemetry.internal.image_processing import frame_generator as \
23    frame_generator_module
24from telemetry.internal.image_processing import video_file_frame_generator
25from telemetry.internal.util import external_modules
26
27np = external_modules.ImportRequiredModule('numpy')
28cv2 = external_modules.ImportRequiredModule('cv2')
29
30
31class 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
840def 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
856if __name__ == '__main__':
857  main()
858