1# Copyright 2016 The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14"""Image processing utilities using openCV.""" 15 16 17import logging 18import math 19import os 20import unittest 21 22import numpy 23 24 25import cv2 26import camera_properties_utils 27import capture_request_utils 28import image_processing_utils 29 30ANGLE_CHECK_TOL = 1 # degrees 31ANGLE_NUM_MIN = 10 # Minimum number of angles for find_angle() to be valid 32 33 34TEST_IMG_DIR = os.path.join(os.environ['CAMERA_ITS_TOP'], 'test_images') 35CHART_FILE = os.path.join(TEST_IMG_DIR, 'ISO12233.png') 36CHART_HEIGHT = 13.5 # cm 37CHART_DISTANCE_RFOV = 31.0 # cm 38CHART_DISTANCE_WFOV = 22.0 # cm 39CHART_SCALE_START = 0.65 40CHART_SCALE_STOP = 1.35 41CHART_SCALE_STEP = 0.025 42 43CIRCLE_AR_ATOL = 0.1 # circle aspect ratio tolerance 44CIRCLISH_ATOL = 0.10 # contour area vs ideal circle area & aspect ratio TOL 45CIRCLISH_LOW_RES_ATOL = 0.15 # loosen for low res images 46CIRCLE_MIN_PTS = 20 47CIRCLE_RADIUS_NUMPTS_THRESH = 2 # contour num_pts/radius: empirically ~3x 48 49CV2_RED = (255, 0, 0) # color in cv2 to draw lines 50 51FOV_THRESH_TELE25 = 25 52FOV_THRESH_TELE40 = 40 53FOV_THRESH_TELE = 60 54FOV_THRESH_WFOV = 90 55 56LOW_RES_IMG_THRESH = 320 * 240 57 58RGB_GRAY_WEIGHTS = (0.299, 0.587, 0.114) # RGB to Gray conversion matrix 59 60SCALE_RFOV_IN_WFOV_BOX = 0.67 61SCALE_TELE_IN_WFOV_BOX = 0.5 62SCALE_TELE_IN_RFOV_BOX = 0.67 63SCALE_TELE40_IN_RFOV_BOX = 0.5 64SCALE_TELE25_IN_RFOV_BOX = 0.33 65 66SQUARE_AREA_MIN_REL = 0.05 # Minimum size for square relative to image area 67SQUARE_TOL = 0.1 # Square W vs H mismatch RTOL 68 69VGA_HEIGHT = 480 70VGA_WIDTH = 640 71 72 73def find_all_contours(img): 74 cv2_version = cv2.__version__ 75 if cv2_version.startswith('3.'): # OpenCV 3.x 76 _, contours, _ = cv2.findContours(img, cv2.RETR_TREE, 77 cv2.CHAIN_APPROX_SIMPLE) 78 else: # OpenCV 2.x and 4.x 79 contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) 80 return contours 81 82 83def calc_chart_scaling(chart_distance, camera_fov): 84 """Returns charts scaling factor. 85 86 Args: 87 chart_distance: float; distance in cm from camera of displayed chart 88 camera_fov: float; camera field of view. 89 90 Returns: 91 chart_scaling: float; scaling factor for chart 92 """ 93 chart_scaling = 1.0 94 camera_fov = float(camera_fov) 95 if (FOV_THRESH_TELE < camera_fov < FOV_THRESH_WFOV and 96 numpy.isclose(chart_distance, CHART_DISTANCE_WFOV, rtol=0.1)): 97 chart_scaling = SCALE_RFOV_IN_WFOV_BOX 98 elif (camera_fov <= FOV_THRESH_TELE and 99 numpy.isclose(chart_distance, CHART_DISTANCE_WFOV, rtol=0.1)): 100 chart_scaling = SCALE_TELE_IN_WFOV_BOX 101 elif (camera_fov <= FOV_THRESH_TELE25 and 102 numpy.isclose(chart_distance, CHART_DISTANCE_RFOV, rtol=0.1)): 103 chart_scaling = SCALE_TELE25_IN_RFOV_BOX 104 elif (camera_fov <= FOV_THRESH_TELE40 and 105 numpy.isclose(chart_distance, CHART_DISTANCE_RFOV, rtol=0.1)): 106 chart_scaling = SCALE_TELE40_IN_RFOV_BOX 107 elif (camera_fov <= FOV_THRESH_TELE and 108 numpy.isclose(chart_distance, CHART_DISTANCE_RFOV, rtol=0.1)): 109 chart_scaling = SCALE_TELE_IN_RFOV_BOX 110 return chart_scaling 111 112 113def scale_img(img, scale=1.0): 114 """Scale image based on a real number scale factor.""" 115 dim = (int(img.shape[1] * scale), int(img.shape[0] * scale)) 116 return cv2.resize(img.copy(), dim, interpolation=cv2.INTER_AREA) 117 118 119def gray_scale_img(img): 120 """Return gray scale version of image.""" 121 if len(img.shape) == 2: 122 img_gray = img.copy() 123 elif len(img.shape) == 3: 124 if img.shape[2] == 1: 125 img_gray = img[:, :, 0].copy() 126 else: 127 img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) 128 return img_gray 129 130 131class Chart(object): 132 """Definition for chart object. 133 134 Defines PNG reference file, chart, size, distance and scaling range. 135 """ 136 137 def __init__( 138 self, 139 cam, 140 props, 141 log_path, 142 chart_loc=None, 143 chart_file=None, 144 height=None, 145 distance=None, 146 scale_start=None, 147 scale_stop=None, 148 scale_step=None): 149 """Initial constructor for class. 150 151 Args: 152 cam: open ITS session 153 props: camera properties object 154 log_path: log path to store the captured images. 155 chart_loc: chart locator arg. 156 chart_file: str; absolute path to png file of chart 157 height: float; height in cm of displayed chart 158 distance: float; distance in cm from camera of displayed chart 159 scale_start: float; start value for scaling for chart search 160 scale_stop: float; stop value for scaling for chart search 161 scale_step: float; step value for scaling for chart search 162 """ 163 self._file = chart_file or CHART_FILE 164 self._height = height or CHART_HEIGHT 165 self._distance = distance or CHART_DISTANCE_RFOV 166 self._scale_start = scale_start or CHART_SCALE_START 167 self._scale_stop = scale_stop or CHART_SCALE_STOP 168 self._scale_step = scale_step or CHART_SCALE_STEP 169 self.xnorm, self.ynorm, self.wnorm, self.hnorm, self.scale = ( 170 image_processing_utils.chart_located_per_argv(chart_loc)) 171 if not self.xnorm: 172 if camera_properties_utils.read_3a(props): 173 self.locate(cam, props, log_path) 174 else: 175 logging.debug('Chart locator skipped.') 176 self._set_scale_factors_to_one() 177 178 def _set_scale_factors_to_one(self): 179 """Set scale factors to 1.0 for skipped tests.""" 180 self.wnorm = 1.0 181 self.hnorm = 1.0 182 self.xnorm = 0.0 183 self.ynorm = 0.0 184 self.scale = 1.0 185 186 def _calc_scale_factors(self, cam, props, fmt, s, e, fd, log_path): 187 """Take an image with s, e, & fd to find the chart location. 188 189 Args: 190 cam: An open its session. 191 props: Properties of cam 192 fmt: Image format for the capture 193 s: Sensitivity for the AF request as defined in 194 android.sensor.sensitivity 195 e: Exposure time for the AF request as defined in 196 android.sensor.exposureTime 197 fd: float; autofocus lens position 198 log_path: log path to save the captured images. 199 200 Returns: 201 template: numpy array; chart template for locator 202 img_3a: numpy array; RGB image for chart location 203 scale_factor: float; scaling factor for chart search 204 """ 205 req = capture_request_utils.manual_capture_request(s, e) 206 req['android.lens.focusDistance'] = fd 207 cap_chart = image_processing_utils.stationary_lens_cap(cam, req, fmt) 208 img_3a = image_processing_utils.convert_capture_to_rgb_image( 209 cap_chart, props) 210 img_3a = image_processing_utils.rotate_img_per_argv(img_3a) 211 af_scene_name = os.path.join(log_path, 'af_scene.jpg') 212 image_processing_utils.write_image(img_3a, af_scene_name) 213 template = cv2.imread(self._file, cv2.IMREAD_ANYDEPTH) 214 focal_l = cap_chart['metadata']['android.lens.focalLength'] 215 pixel_pitch = ( 216 props['android.sensor.info.physicalSize']['height'] / img_3a.shape[0]) 217 logging.debug('Chart distance: %.2fcm', self._distance) 218 logging.debug('Chart height: %.2fcm', self._height) 219 logging.debug('Focal length: %.2fmm', focal_l) 220 logging.debug('Pixel pitch: %.2fum', pixel_pitch * 1E3) 221 logging.debug('Template height: %dpixels', template.shape[0]) 222 chart_pixel_h = self._height * focal_l / (self._distance * pixel_pitch) 223 scale_factor = template.shape[0] / chart_pixel_h 224 logging.debug('Chart/image scale factor = %.2f', scale_factor) 225 return template, img_3a, scale_factor 226 227 def locate(self, cam, props, log_path): 228 """Find the chart in the image, and append location to chart object. 229 230 Args: 231 cam: Open its session. 232 props: Camera properties object. 233 log_path: log path to store the captured images. 234 235 The values appended are: 236 xnorm: float; [0, 1] left loc of chart in scene 237 ynorm: float; [0, 1] top loc of chart in scene 238 wnorm: float; [0, 1] width of chart in scene 239 hnorm: float; [0, 1] height of chart in scene 240 scale: float; scale factor to extract chart 241 """ 242 if camera_properties_utils.read_3a(props): 243 s, e, _, _, fd = cam.do_3a(get_results=True) 244 fmt = {'format': 'yuv', 'width': VGA_WIDTH, 'height': VGA_HEIGHT} 245 chart, scene, s_factor = self._calc_scale_factors(cam, props, fmt, s, e, 246 fd, log_path) 247 else: 248 logging.debug('Chart locator skipped.') 249 self._set_scale_factors_to_one() 250 return 251 scale_start = self._scale_start * s_factor 252 scale_stop = self._scale_stop * s_factor 253 scale_step = self._scale_step * s_factor 254 self.scale = s_factor 255 max_match = [] 256 # check for normalized image 257 if numpy.amax(scene) <= 1.0: 258 scene = (scene * 255.0).astype(numpy.uint8) 259 scene_gray = gray_scale_img(scene) 260 logging.debug('Finding chart in scene...') 261 for scale in numpy.arange(scale_start, scale_stop, scale_step): 262 scene_scaled = scale_img(scene_gray, scale) 263 if (scene_scaled.shape[0] < chart.shape[0] or 264 scene_scaled.shape[1] < chart.shape[1]): 265 continue 266 result = cv2.matchTemplate(scene_scaled, chart, cv2.TM_CCOEFF) 267 _, opt_val, _, top_left_scaled = cv2.minMaxLoc(result) 268 logging.debug(' scale factor: %.3f, opt val: %.f', scale, opt_val) 269 max_match.append((opt_val, top_left_scaled)) 270 271 # determine if optimization results are valid 272 opt_values = [x[0] for x in max_match] 273 if 2.0 * min(opt_values) > max(opt_values): 274 estring = ('Warning: unable to find chart in scene!\n' 275 'Check camera distance and self-reported ' 276 'pixel pitch, focal length and hyperfocal distance.') 277 logging.warning(estring) 278 self._set_scale_factors_to_one() 279 else: 280 if (max(opt_values) == opt_values[0] or 281 max(opt_values) == opt_values[len(opt_values) - 1]): 282 estring = ('Warning: Chart is at extreme range of locator.') 283 logging.warning(estring) 284 # find max and draw bbox 285 match_index = max_match.index(max(max_match, key=lambda x: x[0])) 286 self.scale = scale_start + scale_step * match_index 287 logging.debug('Optimum scale factor: %.3f', self.scale) 288 top_left_scaled = max_match[match_index][1] 289 h, w = chart.shape 290 bottom_right_scaled = (top_left_scaled[0] + w, top_left_scaled[1] + h) 291 top_left = ((top_left_scaled[0] // self.scale), 292 (top_left_scaled[1] // self.scale)) 293 bottom_right = ((bottom_right_scaled[0] // self.scale), 294 (bottom_right_scaled[1] // self.scale)) 295 self.wnorm = ((bottom_right[0]) - top_left[0]) / scene.shape[1] 296 self.hnorm = ((bottom_right[1]) - top_left[1]) / scene.shape[0] 297 self.xnorm = (top_left[0]) / scene.shape[1] 298 self.ynorm = (top_left[1]) / scene.shape[0] 299 300 301def component_shape(contour): 302 """Measure the shape of a connected component. 303 304 Args: 305 contour: return from cv2.findContours. A list of pixel coordinates of 306 the contour. 307 308 Returns: 309 The most left, right, top, bottom pixel location, height, width, and 310 the center pixel location of the contour. 311 """ 312 shape = {'left': numpy.inf, 'right': 0, 'top': numpy.inf, 'bottom': 0, 313 'width': 0, 'height': 0, 'ctx': 0, 'cty': 0} 314 for pt in contour: 315 if pt[0][0] < shape['left']: 316 shape['left'] = pt[0][0] 317 if pt[0][0] > shape['right']: 318 shape['right'] = pt[0][0] 319 if pt[0][1] < shape['top']: 320 shape['top'] = pt[0][1] 321 if pt[0][1] > shape['bottom']: 322 shape['bottom'] = pt[0][1] 323 shape['width'] = shape['right'] - shape['left'] + 1 324 shape['height'] = shape['bottom'] - shape['top'] + 1 325 shape['ctx'] = (shape['left'] + shape['right']) // 2 326 shape['cty'] = (shape['top'] + shape['bottom']) // 2 327 return shape 328 329 330def find_circle(img, img_name, min_area, color): 331 """Find the circle in the test image. 332 333 Args: 334 img: numpy image array in RGB, with pixel values in [0,255]. 335 img_name: string with image info of format and size. 336 min_area: float of minimum area of circle to find 337 color: int of [0 or 255] 0 is black, 255 is white 338 339 Returns: 340 circle = {'x', 'y', 'r', 'w', 'h', 'x_offset', 'y_offset'} 341 """ 342 circle = {} 343 img_size = img.shape 344 if img_size[0]*img_size[1] >= LOW_RES_IMG_THRESH: 345 circlish_atol = CIRCLISH_ATOL 346 else: 347 circlish_atol = CIRCLISH_LOW_RES_ATOL 348 349 # convert to gray-scale image 350 img_gray = numpy.dot(img[..., :3], RGB_GRAY_WEIGHTS) 351 352 # otsu threshold to binarize the image 353 _, img_bw = cv2.threshold(numpy.uint8(img_gray), 0, 255, 354 cv2.THRESH_BINARY + cv2.THRESH_OTSU) 355 356 # find contours 357 contours = find_all_contours(255-img_bw) 358 359 # Check each contour and find the circle bigger than min_area 360 num_circles = 0 361 logging.debug('Initial number of contours: %d', len(contours)) 362 for contour in contours: 363 area = cv2.contourArea(contour) 364 num_pts = len(contour) 365 if (area > img_size[0]*img_size[1]*min_area and 366 num_pts >= CIRCLE_MIN_PTS): 367 shape = component_shape(contour) 368 radius = (shape['width'] + shape['height']) / 4 369 colour = img_bw[shape['cty']][shape['ctx']] 370 circlish = (math.pi * radius**2) / area 371 aspect_ratio = shape['width'] / shape['height'] 372 logging.debug('Potential circle found. radius: %.2f, color: %d, ' 373 'circlish: %.3f, ar: %.3f, pts: %d', radius, colour, 374 circlish, aspect_ratio, num_pts) 375 if (colour == color and 376 numpy.isclose(1.0, circlish, atol=circlish_atol) and 377 numpy.isclose(1.0, aspect_ratio, atol=CIRCLE_AR_ATOL) and 378 num_pts/radius >= CIRCLE_RADIUS_NUMPTS_THRESH): 379 380 # Populate circle dictionary 381 circle['x'] = shape['ctx'] 382 circle['y'] = shape['cty'] 383 circle['r'] = (shape['width'] + shape['height']) / 4 384 circle['w'] = float(shape['width']) 385 circle['h'] = float(shape['height']) 386 circle['x_offset'] = (shape['ctx'] - img_size[1]//2) / circle['w'] 387 circle['y_offset'] = (shape['cty'] - img_size[0]//2) / circle['h'] 388 logging.debug('Num pts: %d', num_pts) 389 logging.debug('Aspect ratio: %.3f', aspect_ratio) 390 logging.debug('Circlish value: %.3f', circlish) 391 logging.debug('Location: %.1f x %.1f', circle['x'], circle['y']) 392 logging.debug('Radius: %.3f', circle['r']) 393 logging.debug('Circle center position wrt to image center:%.3fx%.3f', 394 circle['x_offset'], circle['y_offset']) 395 num_circles += 1 396 # if more than one circle found, break 397 if num_circles == 2: 398 break 399 400 if num_circles == 0: 401 image_processing_utils.write_image(img/255, img_name, True) 402 raise AssertionError('No black circle detected. ' 403 'Please take pictures according to instructions.') 404 405 if num_circles > 1: 406 image_processing_utils.write_image(img/255, img_name, True) 407 raise AssertionError('More than 1 black circle detected. ' 408 'Background of scene may be too complex.') 409 410 return circle 411 412 413def append_circle_center_to_img(circle, img, img_name): 414 """Append circle center and image center to image and save image. 415 416 Draws line from circle center to image center and then labels end-points. 417 Adjusts text positioning depending on circle center wrt image center. 418 Moves text position left/right half of up/down movement for visual aesthetics. 419 420 Args: 421 circle: dict with circle location vals. 422 img: numpy float image array in RGB, with pixel values in [0,255]. 423 img_name: string with image info of format and size. 424 """ 425 line_width_scaling_factor = 500 426 text_move_scaling_factor = 3 427 img_size = img.shape 428 img_center_x = img_size[1]//2 429 img_center_y = img_size[0]//2 430 431 # draw line from circle to image center 432 line_width = int(max(1, max(img_size)//line_width_scaling_factor)) 433 font_size = line_width // 2 434 move_text_dist = line_width * text_move_scaling_factor 435 cv2.line(img, (circle['x'], circle['y']), (img_center_x, img_center_y), 436 CV2_RED, line_width) 437 438 # adjust text location 439 move_text_right_circle = -1 440 move_text_right_image = 2 441 if circle['x'] > img_center_x: 442 move_text_right_circle = 2 443 move_text_right_image = -1 444 445 move_text_down_circle = -1 446 move_text_down_image = 4 447 if circle['y'] > img_center_y: 448 move_text_down_circle = 4 449 move_text_down_image = -1 450 451 # add circles to end points and label 452 radius_pt = line_width * 2 # makes a dot 2x line width 453 filled_pt = -1 # cv2 value for a filled circle 454 # circle center 455 cv2.circle(img, (circle['x'], circle['y']), radius_pt, CV2_RED, filled_pt) 456 text_circle_x = move_text_dist * move_text_right_circle + circle['x'] 457 text_circle_y = move_text_dist * move_text_down_circle + circle['y'] 458 cv2.putText(img, 'circle center', (text_circle_x, text_circle_y), 459 cv2.FONT_HERSHEY_SIMPLEX, font_size, CV2_RED, line_width) 460 # image center 461 cv2.circle(img, (img_center_x, img_center_y), radius_pt, CV2_RED, filled_pt) 462 text_imgct_x = move_text_dist * move_text_right_image + img_center_x 463 text_imgct_y = move_text_dist * move_text_down_image + img_center_y 464 cv2.putText(img, 'image center', (text_imgct_x, text_imgct_y), 465 cv2.FONT_HERSHEY_SIMPLEX, font_size, CV2_RED, line_width) 466 image_processing_utils.write_image(img/255, img_name, True) # [0, 1] values 467 468 469def get_angle(input_img): 470 """Computes anglular inclination of chessboard in input_img. 471 472 Args: 473 input_img (2D numpy.ndarray): Grayscale image stored as a 2D numpy array. 474 Returns: 475 Median angle of squares in degrees identified in the image. 476 477 Angle estimation algorithm description: 478 Input: 2D grayscale image of chessboard. 479 Output: Angle of rotation of chessboard perpendicular to 480 chessboard. Assumes chessboard and camera are parallel to 481 each other. 482 483 1) Use adaptive threshold to make image binary 484 2) Find countours 485 3) Filter out small contours 486 4) Filter out all non-square contours 487 5) Compute most common square shape. 488 The assumption here is that the most common square instances are the 489 chessboard squares. We've shown that with our current tuning, we can 490 robustly identify the squares on the sensor fusion chessboard. 491 6) Return median angle of most common square shape. 492 493 USAGE NOTE: This function has been tuned to work for the chessboard used in 494 the sensor_fusion tests. See images in test_images/rotated_chessboard/ for 495 sample captures. If this function is used with other chessboards, it may not 496 work as expected. 497 """ 498 # Tuning parameters 499 square_area_min = (float)(input_img.shape[1] * SQUARE_AREA_MIN_REL) 500 501 # Creates copy of image to avoid modifying original. 502 img = numpy.array(input_img, copy=True) 503 504 # Scale pixel values from 0-1 to 0-255 505 img *= 255 506 img = img.astype(numpy.uint8) 507 img_thresh = cv2.adaptiveThreshold( 508 img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 201, 2) 509 510 # Find all contours. 511 contours = find_all_contours(img_thresh) 512 513 # Filter contours to squares only. 514 square_contours = [] 515 for contour in contours: 516 rect = cv2.minAreaRect(contour) 517 _, (width, height), angle = rect 518 519 # Skip non-squares 520 if not numpy.isclose(width, height, rtol=SQUARE_TOL): 521 continue 522 523 # Remove very small contours: usually just tiny dots due to noise. 524 area = cv2.contourArea(contour) 525 if area < square_area_min: 526 continue 527 528 square_contours.append(contour) 529 530 areas = [] 531 for contour in square_contours: 532 area = cv2.contourArea(contour) 533 areas.append(area) 534 535 median_area = numpy.median(areas) 536 537 filtered_squares = [] 538 filtered_angles = [] 539 for square in square_contours: 540 area = cv2.contourArea(square) 541 if not numpy.isclose(area, median_area, rtol=SQUARE_TOL): 542 continue 543 544 filtered_squares.append(square) 545 _, (width, height), angle = cv2.minAreaRect(square) 546 filtered_angles.append(angle) 547 548 if len(filtered_angles) < ANGLE_NUM_MIN: 549 logging.debug( 550 'A frame had too few angles to be processed. ' 551 'Num of angles: %d, MIN: %d', len(filtered_angles), ANGLE_NUM_MIN) 552 return None 553 554 return numpy.median(filtered_angles) 555 556 557class Cv2ImageProcessingUtilsTests(unittest.TestCase): 558 """Unit tests for this module.""" 559 560 def test_get_angle_identify_unrotated_chessboard_angle(self): 561 normal_img_path = os.path.join( 562 TEST_IMG_DIR, 'rotated_chessboards/normal.jpg') 563 wide_img_path = os.path.join( 564 TEST_IMG_DIR, 'rotated_chessboards/wide.jpg') 565 normal_img = cv2.cvtColor(cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY) 566 wide_img = cv2.cvtColor(cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY) 567 normal_angle = get_angle(normal_img) 568 wide_angle = get_angle(wide_img) 569 e_msg = f'Angle: 0, Regular: {normal_angle}, Wide: {wide_angle}' 570 self.assertEqual(get_angle(normal_img), 0, e_msg) 571 self.assertEqual(get_angle(wide_img), 0, e_msg) 572 573 def test_get_angle_identify_rotated_chessboard_angle(self): 574 # Array of the image files and angles containing rotated chessboards. 575 test_cases = [ 576 ('_15_ccw', 15), 577 ('_30_ccw', 30), 578 ('_45_ccw', 45), 579 ('_60_ccw', 60), 580 ('_75_ccw', 75), 581 ('_90_ccw', 90) 582 ] 583 584 # For each rotated image pair (normal, wide), check angle against expected. 585 for suffix, angle in test_cases: 586 # Define image paths. 587 normal_img_path = os.path.join( 588 TEST_IMG_DIR, f'rotated_chessboards/normal{suffix}.jpg') 589 wide_img_path = os.path.join( 590 TEST_IMG_DIR, f'rotated_chessboards/wide{suffix}.jpg') 591 592 # Load and color-convert images. 593 normal_img = cv2.cvtColor(cv2.imread(normal_img_path), cv2.COLOR_BGR2GRAY) 594 wide_img = cv2.cvtColor(cv2.imread(wide_img_path), cv2.COLOR_BGR2GRAY) 595 596 # Assert angle as expected. 597 normal_angle = get_angle(normal_img) 598 wide_angle = get_angle(wide_img) 599 e_msg = f'Angle: {angle}, Regular: {normal_angle}, Wide: {wide_angle}' 600 self.assertTrue( 601 numpy.isclose(abs(normal_angle), angle, ANGLE_CHECK_TOL), e_msg) 602 self.assertTrue( 603 numpy.isclose(abs(wide_angle), angle, ANGLE_CHECK_TOL), e_msg) 604 605 606if __name__ == '__main__': 607 unittest.main() 608