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