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