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