Rucha Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 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. |
Clemenz Portmann | cddf2bf | 2021-03-19 13:21:16 -0700 | [diff] [blame] | 14 | """Image processing utilities using openCV.""" |
Rucha Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 15 | |
| 16 | |
| 17 | import logging |
| 18 | import math |
| 19 | import os |
| 20 | import unittest |
| 21 | |
| 22 | import numpy |
| 23 | |
| 24 | |
| 25 | import cv2 |
| 26 | import camera_properties_utils |
| 27 | import capture_request_utils |
| 28 | import image_processing_utils |
| 29 | |
| 30 | ANGLE_CHECK_TOL = 1 # degrees |
| 31 | ANGLE_NUM_MIN = 10 # Minimum number of angles for find_angle() to be valid |
| 32 | |
| 33 | |
Clemenz Portmann | 3929a98 | 2021-03-19 10:47:06 -0700 | [diff] [blame] | 34 | TEST_IMG_DIR = os.path.join(os.environ['CAMERA_ITS_TOP'], 'test_images') |
| 35 | CHART_FILE = os.path.join(TEST_IMG_DIR, 'ISO12233.png') |
Rucha Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 36 | CHART_HEIGHT = 13.5 # cm |
| 37 | CHART_DISTANCE_RFOV = 31.0 # cm |
| 38 | CHART_DISTANCE_WFOV = 22.0 # cm |
| 39 | CHART_SCALE_START = 0.65 |
| 40 | CHART_SCALE_STOP = 1.35 |
| 41 | CHART_SCALE_STEP = 0.025 |
| 42 | |
| 43 | CIRCLE_AR_ATOL = 0.1 # circle aspect ratio tolerance |
Clemenz Portmann | 4fc9f67 | 2021-02-11 15:41:50 -0800 | [diff] [blame] | 44 | CIRCLISH_ATOL = 0.10 # contour area vs ideal circle area & aspect ratio TOL |
| 45 | CIRCLISH_LOW_RES_ATOL = 0.15 # loosen for low res images |
Rucha Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 46 | CIRCLE_MIN_PTS = 20 |
| 47 | CIRCLE_RADIUS_NUMPTS_THRESH = 2 # contour num_pts/radius: empirically ~3x |
| 48 | |
| 49 | CV2_RED = (255, 0, 0) # color in cv2 to draw lines |
| 50 | |
| 51 | FOV_THRESH_SUPER_TELE = 40 |
| 52 | FOV_THRESH_TELE = 60 |
| 53 | FOV_THRESH_WFOV = 90 |
| 54 | |
Clemenz Portmann | 4fc9f67 | 2021-02-11 15:41:50 -0800 | [diff] [blame] | 55 | LOW_RES_IMG_THRESH = 320 * 240 |
| 56 | |
Rucha Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 57 | RGB_GRAY_WEIGHTS = (0.299, 0.587, 0.114) # RGB to Gray conversion matrix |
| 58 | |
| 59 | SCALE_RFOV_IN_WFOV_BOX = 0.67 |
| 60 | SCALE_TELE_IN_RFOV_BOX = 0.67 |
| 61 | SCALE_TELE_IN_WFOV_BOX = 0.5 |
Clemenz Portmann | 346b404 | 2021-03-08 09:27:26 -0800 | [diff] [blame] | 62 | SCALE_SUPER_TELE_IN_RFOV_BOX = 0.5 |
Rucha Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 63 | |
| 64 | SQUARE_AREA_MIN_REL = 0.05 # Minimum size for square relative to image area |
| 65 | SQUARE_TOL = 0.1 # Square W vs H mismatch RTOL |
| 66 | |
| 67 | VGA_HEIGHT = 480 |
| 68 | VGA_WIDTH = 640 |
| 69 | |
| 70 | |
| 71 | def 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 | |
| 81 | def 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 Portmann | 346b404 | 2021-03-08 09:27:26 -0800 | [diff] [blame] | 99 | 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 Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 102 | 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 | |
| 108 | def 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 | |
| 114 | def 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 | |
| 126 | class 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 | |
| 296 | def 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 | |
| 325 | def 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 Portmann | 4fc9f67 | 2021-02-11 15:41:50 -0800 | [diff] [blame] | 339 | 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 Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 343 | |
| 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 Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 367 | 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 Portmann | 4fc9f67 | 2021-02-11 15:41:50 -0800 | [diff] [blame] | 371 | numpy.isclose(1.0, circlish, atol=circlish_atol) and |
Rucha Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 372 | 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 Portmann | 7923273 | 2021-02-23 14:16:10 -0800 | [diff] [blame] | 397 | raise AssertionError('No black circle detected. ' |
| 398 | 'Please take pictures according to instructions.') |
Rucha Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 399 | |
| 400 | if num_circles > 1: |
| 401 | image_processing_utils.write_image(img/255, img_name, True) |
Clemenz Portmann | 7923273 | 2021-02-23 14:16:10 -0800 | [diff] [blame] | 402 | raise AssertionError('More than 1 black circle detected. ' |
| 403 | 'Background of scene may be too complex.') |
Rucha Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 404 | |
| 405 | return circle |
| 406 | |
| 407 | |
| 408 | def 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 | |
| 464 | def 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 | |
| 552 | class Cv2ImageProcessingUtilsTests(unittest.TestCase): |
| 553 | """Unit tests for this module.""" |
| 554 | |
| 555 | def test_get_angle_identify_unrotated_chessboard_angle(self): |
Clemenz Portmann | 452eead | 2021-02-08 13:55:12 -0800 | [diff] [blame] | 556 | 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 Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 560 | 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 Portmann | 452eead | 2021-02-08 13:55:12 -0800 | [diff] [blame] | 582 | 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 Katakwar | b83e59a | 2021-02-02 17:16:54 -0800 | [diff] [blame] | 586 | |
| 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 | |
| 601 | if __name__ == '__main__': |
| 602 | unittest.main() |