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 pathlib 21import cv2 22import numpy 23import scipy.spatial 24 25import camera_properties_utils 26import capture_request_utils 27import error_util 28import image_processing_utils 29 30AE_AWB_METER_WEIGHT = 1000 # 1 - 1000 with 1000 the highest 31ANGLE_CHECK_TOL = 1 # degrees 32ANGLE_NUM_MIN = 10 # Minimum number of angles for find_angle() to be valid 33ARUCO_DETECTOR_ATTRIBUTE_NAME = 'ArucoDetector' 34ARUCO_CORNER_COUNT = 4 # total of 4 corners to a aruco marker 35 36TEST_IMG_DIR = os.path.join(os.environ['CAMERA_ITS_TOP'], 'test_images') 37CH_FULL_SCALE = 255 38CHART_FILE = os.path.join(TEST_IMG_DIR, 'ISO12233.png') 39CHART_HEIGHT_31CM = 13.5 # cm height of chart for 31cm distance chart 40CHART_HEIGHT_22CM = 9.5 # cm height of chart for 22cm distance chart 41CHART_DISTANCE_90CM = 90.0 # cm 42CHART_DISTANCE_31CM = 31.0 # cm 43CHART_DISTANCE_22CM = 22.0 # cm 44CHART_SCALE_RTOL = 0.1 45CHART_SCALE_START = 0.65 46CHART_SCALE_STOP = 1.35 47CHART_SCALE_STEP = 0.025 48 49CIRCLE_AR_ATOL = 0.1 # circle aspect ratio tolerance 50CIRCLISH_ATOL = 0.10 # contour area vs ideal circle area & aspect ratio TOL 51CIRCLISH_LOW_RES_ATOL = 0.15 # loosen for low res images 52CIRCLE_MIN_PTS = 20 53CIRCLE_RADIUS_NUMPTS_THRESH = 2 # contour num_pts/radius: empirically ~3x 54CIRCLE_COLOR_ATOL = 0.05 # circle color fill tolerance 55CIRCLE_LOCATION_VARIATION_RTOL = 0.05 # tolerance to remove similar circles 56 57CV2_CONTRAST_ALPHA = 1.25 # contrast 58CV2_CONTRAST_BETA = 0 # brightness 59CV2_THESHOLD_LOWER_BLACK = 0 60CV2_LINE_THICKNESS = 3 # line thickness for drawing on images 61CV2_BLACK = (0, 0, 0) 62CV2_BLUE = (0, 0, 255) 63CV2_RED = (255, 0, 0) # color in cv2 to draw lines 64CV2_RED_NORM = tuple(numpy.array(CV2_RED) / 255) 65CV2_GREEN = (0, 255, 0) 66CV2_GREEN_NORM = tuple(numpy.array(CV2_GREEN) / 255) 67CV2_WHITE = (255, 255, 255) 68CV2_YELLOW = (255, 255, 0) 69CV2_THRESHOLD_BLOCK_SIZE = 11 70CV2_THRESHOLD_CONSTANT = 2 71CV2_ZOOM_MARKER_SIZE = 30 72CV2_ZOOM_MARKER_THICKNESS = 3 73 74CV2_HOME_DIRECTORY = os.path.dirname(cv2.__file__) 75CV2_ALTERNATE_DIRECTORY = pathlib.Path(CV2_HOME_DIRECTORY).parents[3] 76HAARCASCADE_FILE_NAME = 'haarcascade_frontalface_default.xml' 77 78FACES_ALIGNED_MIN_NUM = 2 79FACE_CENTER_MATCH_TOL_X = 10 # 10 pixels or ~1.5% in 640x480 image 80FACE_CENTER_MATCH_TOL_Y = 20 # 20 pixels or ~4% in 640x480 image 81FACE_CENTER_MIN_LOGGING_DIST = 50 82FACE_MIN_CENTER_DELTA = 15 83 84FOV_THRESH_TELE25 = 25 85FOV_THRESH_TELE40 = 40 86FOV_THRESH_TELE = 60 87FOV_THRESH_UW = 90 88 89IMAGE_ROTATION_THRESHOLD = 40 # rotation by 20 pixels 90 91LOW_RES_IMG_THRESH = 320 * 240 92 93NUM_AE_AWB_REGIONS = 4 94 95OPT_VALUE_THRESH = 0.5 # Max opt value is ~0.8 96 97SCALE_CHART_33_PERCENT = 0.33 98SCALE_CHART_67_PERCENT = 0.67 99SCALE_WIDE_IN_22CM_RIG = 0.67 100SCALE_TELE_IN_22CM_RIG = 0.5 101SCALE_TELE_IN_31CM_RIG = 0.67 102SCALE_TELE40_IN_22CM_RIG = 0.33 103SCALE_TELE40_IN_31CM_RIG = 0.5 104SCALE_TELE25_IN_31CM_RIG = 0.33 105 106SQUARE_AREA_MIN_REL = 0.05 # Minimum size for square relative to image area 107SQUARE_CROP_MARGIN = 0 # Set to aid detection of QR codes 108SQUARE_TOL = 0.05 # Square W vs H mismatch RTOL 109SQUARISH_RTOL = 0.10 110SQUARISH_AR_RTOL = 0.10 111 112VGA_HEIGHT = 480 113VGA_WIDTH = 640 114 115 116def convert_to_y(img, color_order='RGB'): 117 """Returns a Y image from a uint8 RGB or BGR ordered image. 118 119 Args: 120 img: a uint8 openCV image. 121 color_order: str; 'RGB' or 'BGR' to signify color plane order. 122 123 Returns: 124 The Y plane of the input img. 125 """ 126 if img.dtype != 'uint8': 127 raise AssertionError(f'Incorrect input type: {img.dtype}! Expected: uint8') 128 if color_order == 'RGB': 129 y, _, _ = cv2.split(cv2.cvtColor(img, cv2.COLOR_RGB2YUV)) 130 elif color_order == 'BGR': 131 y, _, _ = cv2.split(cv2.cvtColor(img, cv2.COLOR_BGR2YUV)) 132 else: 133 raise AssertionError(f'Undefined color order: {color_order}!') 134 return y 135 136 137def binarize_image(img_gray): 138 """Returns a binarized image based on cv2 thresholds. 139 140 Args: 141 img_gray: A grayscale openCV image. 142 Returns: 143 An openCV image binarized to 0 (black) and 255 (white). 144 """ 145 _, img_bw = cv2.threshold(numpy.uint8(img_gray), 0, 255, 146 cv2.THRESH_BINARY + cv2.THRESH_OTSU) 147 return img_bw 148 149 150def _load_opencv_haarcascade_file(): 151 """Return Haar Cascade file for face detection.""" 152 for cv2_directory in (CV2_HOME_DIRECTORY, CV2_ALTERNATE_DIRECTORY,): 153 for path, _, files in os.walk(cv2_directory): 154 if HAARCASCADE_FILE_NAME in files: 155 haarcascade_file = os.path.join(path, HAARCASCADE_FILE_NAME) 156 logging.debug('Haar Cascade file location: %s', haarcascade_file) 157 return haarcascade_file 158 raise error_util.CameraItsError('haarcascade_frontalface_default.xml was ' 159 f'not found in {CV2_HOME_DIRECTORY} ' 160 f'or {CV2_ALTERNATE_DIRECTORY}') 161 162 163def find_opencv_faces(img, scale_factor, min_neighbors): 164 """Finds face rectangles with openCV. 165 166 Args: 167 img: numpy array; 3-D RBG image with [0,1] values 168 scale_factor: float, specifies how much image size is reduced at each scale 169 min_neighbors: int, specifies minimum number of neighbors to keep rectangle 170 Returns: 171 List of rectangles with faces 172 """ 173 # prep opencv 174 opencv_haarcascade_file = _load_opencv_haarcascade_file() 175 face_cascade = cv2.CascadeClassifier(opencv_haarcascade_file) 176 img_uint8 = image_processing_utils.convert_image_to_uint8(img) 177 img_gray = cv2.cvtColor(img_uint8, cv2.COLOR_RGB2GRAY) 178 179 # find face rectangles with opencv 180 faces_opencv = face_cascade.detectMultiScale( 181 img_gray, scale_factor, min_neighbors) 182 logging.debug('%s', str(faces_opencv)) 183 return faces_opencv 184 185 186def find_all_contours(img): 187 cv2_version = cv2.__version__ 188 if cv2_version.startswith('3.'): # OpenCV 3.x 189 _, contours, _ = cv2.findContours(img, cv2.RETR_TREE, 190 cv2.CHAIN_APPROX_SIMPLE) 191 else: # OpenCV 2.x and 4.x 192 contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) 193 return contours 194 195 196def calc_chart_scaling(chart_distance, camera_fov): 197 """Returns charts scaling factor. 198 199 Args: 200 chart_distance: float; distance in cm from camera of displayed chart 201 camera_fov: float; camera field of view. 202 203 Returns: 204 chart_scaling: float; scaling factor for chart 205 """ 206 chart_scaling = 1.0 207 fov = float(camera_fov) 208 is_chart_distance_22cm = math.isclose( 209 chart_distance, CHART_DISTANCE_22CM, rel_tol=CHART_SCALE_RTOL) 210 is_chart_distance_31cm = math.isclose( 211 chart_distance, CHART_DISTANCE_31CM, rel_tol=CHART_SCALE_RTOL) 212 is_chart_distance_90cm = math.isclose( 213 chart_distance, CHART_DISTANCE_90CM, rel_tol=CHART_SCALE_RTOL) 214 215 if FOV_THRESH_TELE < fov < FOV_THRESH_UW and is_chart_distance_22cm: 216 chart_scaling = SCALE_WIDE_IN_22CM_RIG 217 elif FOV_THRESH_TELE40 < fov <= FOV_THRESH_TELE and is_chart_distance_22cm: 218 chart_scaling = SCALE_TELE_IN_22CM_RIG 219 elif fov <= FOV_THRESH_TELE40 and is_chart_distance_22cm: 220 chart_scaling = SCALE_TELE40_IN_22CM_RIG 221 elif fov <= FOV_THRESH_TELE25 and is_chart_distance_31cm: 222 chart_scaling = SCALE_TELE25_IN_31CM_RIG 223 elif fov <= FOV_THRESH_TELE40 and is_chart_distance_31cm: 224 chart_scaling = SCALE_TELE40_IN_31CM_RIG 225 elif fov <= FOV_THRESH_TELE40 and is_chart_distance_90cm: 226 chart_scaling = SCALE_CHART_67_PERCENT 227 elif fov <= FOV_THRESH_TELE and is_chart_distance_31cm: 228 chart_scaling = SCALE_TELE_IN_31CM_RIG 229 elif chart_distance > CHART_DISTANCE_31CM: 230 chart_scaling = SCALE_CHART_33_PERCENT 231 return chart_scaling 232 233 234def scale_img(img, scale=1.0): 235 """Scale image based on a real number scale factor.""" 236 dim = (int(img.shape[1] * scale), int(img.shape[0] * scale)) 237 return cv2.resize(img.copy(), dim, interpolation=cv2.INTER_AREA) 238 239 240class Chart(object): 241 """Definition for chart object. 242 243 Defines PNG reference file, chart, size, distance and scaling range. 244 """ 245 246 def __init__( 247 self, 248 cam, 249 props, 250 log_path, 251 chart_file=None, 252 height=None, 253 distance=None, 254 scale_start=None, 255 scale_stop=None, 256 scale_step=None, 257 rotation=None): 258 """Initial constructor for class. 259 260 Args: 261 cam: open ITS session 262 props: camera properties object 263 log_path: log path to store the captured images. 264 chart_file: str; absolute path to png file of chart 265 height: float; height in cm of displayed chart 266 distance: float; distance in cm from camera of displayed chart 267 scale_start: float; start value for scaling for chart search 268 scale_stop: float; stop value for scaling for chart search 269 scale_step: float; step value for scaling for chart search 270 rotation: clockwise rotation in degrees (multiple of 90) or None 271 """ 272 self._file = chart_file or CHART_FILE 273 if math.isclose( 274 distance, CHART_DISTANCE_31CM, rel_tol=CHART_SCALE_RTOL): 275 self._height = height or CHART_HEIGHT_31CM 276 self._distance = distance 277 else: 278 self._height = height or CHART_HEIGHT_22CM 279 self._distance = CHART_DISTANCE_22CM 280 self._scale_start = scale_start or CHART_SCALE_START 281 self._scale_stop = scale_stop or CHART_SCALE_STOP 282 self._scale_step = scale_step or CHART_SCALE_STEP 283 self.opt_val = None 284 self.locate(cam, props, log_path, rotation) 285 286 def _set_scale_factors_to_one(self): 287 """Set scale factors to 1.0 for skipped tests.""" 288 self.wnorm = 1.0 289 self.hnorm = 1.0 290 self.xnorm = 0.0 291 self.ynorm = 0.0 292 self.scale = 1.0 293 294 def _calc_scale_factors(self, cam, props, fmt, log_path, rotation): 295 """Take an image with s, e, & fd to find the chart location. 296 297 Args: 298 cam: An open its session. 299 props: Properties of cam 300 fmt: Image format for the capture 301 log_path: log path to save the captured images. 302 rotation: clockwise rotation of template in degrees (multiple of 90) or 303 None 304 305 Returns: 306 template: numpy array; chart template for locator 307 img_3a: numpy array; RGB image for chart location 308 scale_factor: float; scaling factor for chart search 309 """ 310 req = capture_request_utils.auto_capture_request() 311 cap_chart = capture_request_utils.stationary_lens_capture(cam, req, fmt) 312 img_3a = image_processing_utils.convert_capture_to_rgb_image( 313 cap_chart, props) 314 img_3a = image_processing_utils.rotate_img_per_argv(img_3a) 315 af_scene_name = os.path.join(log_path, 'af_scene.jpg') 316 image_processing_utils.write_image(img_3a, af_scene_name) 317 template = cv2.imread(self._file, cv2.IMREAD_ANYDEPTH) 318 if rotation is not None: 319 logging.debug('Rotating template by %d degrees', rotation) 320 template = numpy.rot90(template, k=rotation / 90) 321 focal_l = cap_chart['metadata']['android.lens.focalLength'] 322 pixel_pitch = ( 323 props['android.sensor.info.physicalSize']['height'] / img_3a.shape[0]) 324 logging.debug('Chart distance: %.2fcm', self._distance) 325 logging.debug('Chart height: %.2fcm', self._height) 326 logging.debug('Focal length: %.2fmm', focal_l) 327 logging.debug('Pixel pitch: %.2fum', pixel_pitch * 1E3) 328 logging.debug('Template width: %dpixels', template.shape[1]) 329 logging.debug('Template height: %dpixels', template.shape[0]) 330 chart_pixel_h = self._height * focal_l / (self._distance * pixel_pitch) 331 scale_factor = template.shape[0] / chart_pixel_h 332 if rotation == 90 or rotation == 270: 333 # With the landscape to portrait override turned on, the width and height 334 # of the active array, normally w x h, will be h x (w * (h/w)^2). Reduce 335 # the applied scaling by the same factor to compensate for this, because 336 # the chart will take up more of the scene. Assume w > h, since this is 337 # meant for landscape sensors. 338 rotate_physical_aspect = ( 339 props['android.sensor.info.physicalSize']['height'] / 340 props['android.sensor.info.physicalSize']['width']) 341 scale_factor *= rotate_physical_aspect ** 2 342 logging.debug('Chart/image scale factor = %.2f', scale_factor) 343 return template, img_3a, scale_factor 344 345 def locate(self, cam, props, log_path, rotation): 346 """Find the chart in the image, and append location to chart object. 347 348 Args: 349 cam: Open its session. 350 props: Camera properties object. 351 log_path: log path to store the captured images. 352 rotation: clockwise rotation of template in degrees (multiple of 90) or 353 None 354 355 The values appended are: 356 xnorm: float; [0, 1] left loc of chart in scene 357 ynorm: float; [0, 1] top loc of chart in scene 358 wnorm: float; [0, 1] width of chart in scene 359 hnorm: float; [0, 1] height of chart in scene 360 scale: float; scale factor to extract chart 361 opt_val: float; The normalized match optimization value [0, 1] 362 """ 363 fmt = {'format': 'yuv', 'width': VGA_WIDTH, 'height': VGA_HEIGHT} 364 cam.do_3a() 365 chart, scene, s_factor = self._calc_scale_factors(cam, props, fmt, log_path, 366 rotation) 367 scale_start = self._scale_start * s_factor 368 scale_stop = self._scale_stop * s_factor 369 scale_step = self._scale_step * s_factor 370 offset = scale_step / 2 371 self.scale = s_factor 372 logging.debug('scale start: %.3f, stop: %.3f, step: %.3f', 373 scale_start, scale_stop, scale_step) 374 logging.debug('Used offset of %.3f to include stop value.', offset) 375 max_match = [] 376 # convert [0.0, 1.0] image to [0, 255] and then grayscale 377 scene_uint8 = image_processing_utils.convert_image_to_uint8(scene) 378 scene_gray = image_processing_utils.convert_rgb_to_grayscale(scene_uint8) 379 380 # find scene 381 logging.debug('Finding chart in scene...') 382 for scale in numpy.arange(scale_start, scale_stop + offset, scale_step): 383 scene_scaled = scale_img(scene_gray, scale) 384 if (scene_scaled.shape[0] < chart.shape[0] or 385 scene_scaled.shape[1] < chart.shape[1]): 386 logging.debug( 387 'Skipped scale %.3f. scene_scaled shape: %s, chart shape: %s', 388 scale, scene_scaled.shape, chart.shape) 389 continue 390 result = cv2.matchTemplate(scene_scaled, chart, cv2.TM_CCOEFF_NORMED) 391 _, opt_val, _, top_left_scaled = cv2.minMaxLoc(result) 392 logging.debug(' scale factor: %.3f, opt val: %.3f', scale, opt_val) 393 max_match.append((opt_val, scale, top_left_scaled)) 394 395 # determine if optimization results are valid 396 opt_values = [x[0] for x in max_match] 397 if not opt_values or max(opt_values) < OPT_VALUE_THRESH: 398 raise AssertionError( 399 'Unable to find chart in scene!\n' 400 'Check camera distance and self-reported ' 401 'pixel pitch, focal length and hyperfocal distance.') 402 else: 403 # find max and draw bbox 404 matched_scale_and_loc = max(max_match, key=lambda x: x[0]) 405 self.opt_val = matched_scale_and_loc[0] 406 self.scale = matched_scale_and_loc[1] 407 logging.debug('Optimum scale factor: %.3f', self.scale) 408 logging.debug('Opt val: %.3f', self.opt_val) 409 top_left_scaled = matched_scale_and_loc[2] 410 logging.debug('top_left_scaled: %d, %d', top_left_scaled[0], 411 top_left_scaled[1]) 412 h, w = chart.shape 413 bottom_right_scaled = (top_left_scaled[0] + w, top_left_scaled[1] + h) 414 logging.debug('bottom_right_scaled: %d, %d', bottom_right_scaled[0], 415 bottom_right_scaled[1]) 416 top_left = ((top_left_scaled[0] // self.scale), 417 (top_left_scaled[1] // self.scale)) 418 bottom_right = ((bottom_right_scaled[0] // self.scale), 419 (bottom_right_scaled[1] // self.scale)) 420 self.wnorm = ((bottom_right[0]) - top_left[0]) / scene.shape[1] 421 self.hnorm = ((bottom_right[1]) - top_left[1]) / scene.shape[0] 422 self.xnorm = (top_left[0]) / scene.shape[1] 423 self.ynorm = (top_left[1]) / scene.shape[0] 424 patch = image_processing_utils.get_image_patch( 425 scene_uint8, self.xnorm, self.ynorm, self.wnorm, self.hnorm) / 255 426 image_processing_utils.write_image( 427 patch, os.path.join(log_path, 'template_scene.jpg')) 428 429 430def component_shape(contour): 431 """Measure the shape of a connected component. 432 433 Args: 434 contour: return from cv2.findContours. A list of pixel coordinates of 435 the contour. 436 437 Returns: 438 The most left, right, top, bottom pixel location, height, width, and 439 the center pixel location of the contour. 440 """ 441 shape = {'left': numpy.inf, 'right': 0, 'top': numpy.inf, 'bottom': 0, 442 'width': 0, 'height': 0, 'ctx': 0, 'cty': 0} 443 for pt in contour: 444 if pt[0][0] < shape['left']: 445 shape['left'] = pt[0][0] 446 if pt[0][0] > shape['right']: 447 shape['right'] = pt[0][0] 448 if pt[0][1] < shape['top']: 449 shape['top'] = pt[0][1] 450 if pt[0][1] > shape['bottom']: 451 shape['bottom'] = pt[0][1] 452 shape['width'] = shape['right'] - shape['left'] + 1 453 shape['height'] = shape['bottom'] - shape['top'] + 1 454 shape['ctx'] = (shape['left'] + shape['right']) // 2 455 shape['cty'] = (shape['top'] + shape['bottom']) // 2 456 return shape 457 458 459def find_circle_fill_metric(shape, img_bw, color): 460 """Find the proportion of points matching a desired color on a shape's axes. 461 462 Args: 463 shape: dictionary returned by component_shape(...) 464 img_bw: binarized numpy image array 465 color: int of [0 or 255] 0 is black, 255 is white 466 Returns: 467 float: number of x, y axis points matching color / total x, y axis points 468 """ 469 matching = 0 470 total = 0 471 for y in range(shape['top'], shape['bottom']): 472 total += 1 473 matching += 1 if img_bw[y][shape['ctx']] == color else 0 474 for x in range(shape['left'], shape['right']): 475 total += 1 476 matching += 1 if img_bw[shape['cty']][x] == color else 0 477 logging.debug('Found %d matching points out of %d', matching, total) 478 return matching / total 479 480 481def find_circle(img, img_name, min_area, color, use_adaptive_threshold=False): 482 """Find the circle in the test image. 483 484 Args: 485 img: numpy image array in RGB, with pixel values in [0,255]. 486 img_name: string with image info of format and size. 487 min_area: float of minimum area of circle to find 488 color: int of [0 or 255] 0 is black, 255 is white 489 use_adaptive_threshold: True if binarization should use adaptive threshold. 490 491 Returns: 492 circle = {'x', 'y', 'r', 'w', 'h', 'x_offset', 'y_offset'} 493 """ 494 circle = {} 495 img_size = img.shape 496 if img_size[0]*img_size[1] >= LOW_RES_IMG_THRESH: 497 circlish_atol = CIRCLISH_ATOL 498 else: 499 circlish_atol = CIRCLISH_LOW_RES_ATOL 500 501 # convert to gray-scale image and binarize using adaptive/global threshold 502 if use_adaptive_threshold: 503 img_gray = cv2.cvtColor(img.astype(numpy.uint8), cv2.COLOR_BGR2GRAY) 504 img_bw = cv2.adaptiveThreshold(img_gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, 505 cv2.THRESH_BINARY, CV2_THRESHOLD_BLOCK_SIZE, 506 CV2_THRESHOLD_CONSTANT) 507 else: 508 img_gray = image_processing_utils.convert_rgb_to_grayscale(img) 509 img_bw = binarize_image(img_gray) 510 511 # find contours 512 contours = find_all_contours(255-img_bw) 513 514 # Check each contour and find the circle bigger than min_area 515 num_circles = 0 516 circle_contours = [] 517 logging.debug('Initial number of contours: %d', len(contours)) 518 min_circle_area = min_area * img_size[0] * img_size[1] 519 logging.debug('Screening out circles w/ radius < %.1f (pixels) or %d pts.', 520 math.sqrt(min_circle_area / math.pi), CIRCLE_MIN_PTS) 521 for contour in contours: 522 area = cv2.contourArea(contour) 523 num_pts = len(contour) 524 if (area > min_circle_area and num_pts >= CIRCLE_MIN_PTS): 525 shape = component_shape(contour) 526 radius = (shape['width'] + shape['height']) / 4 527 colour = img_bw[shape['cty']][shape['ctx']] 528 circlish = (math.pi * radius**2) / area 529 aspect_ratio = shape['width'] / shape['height'] 530 fill = find_circle_fill_metric(shape, img_bw, color) 531 logging.debug('Potential circle found. radius: %.2f, color: %d, ' 532 'circlish: %.3f, ar: %.3f, pts: %d, fill metric: %.3f', 533 radius, colour, circlish, aspect_ratio, num_pts, fill) 534 if (colour == color and 535 math.isclose(1.0, circlish, abs_tol=circlish_atol) and 536 math.isclose(1.0, aspect_ratio, abs_tol=CIRCLE_AR_ATOL) and 537 num_pts/radius >= CIRCLE_RADIUS_NUMPTS_THRESH and 538 math.isclose(1.0, fill, abs_tol=CIRCLE_COLOR_ATOL)): 539 radii = [ 540 image_processing_utils.distance( 541 (shape['ctx'], shape['cty']), numpy.squeeze(point)) 542 for point in contour 543 ] 544 minimum_radius, maximum_radius = min(radii), max(radii) 545 logging.debug('Minimum radius: %.2f, maximum radius: %.2f', 546 minimum_radius, maximum_radius) 547 if circle: 548 old_circle_center = (circle['x'], circle['y']) 549 new_circle_center = (shape['ctx'], shape['cty']) 550 # Based on image height 551 center_distance_atol = img_size[0]*CIRCLE_LOCATION_VARIATION_RTOL 552 if math.isclose( 553 image_processing_utils.distance( 554 old_circle_center, new_circle_center), 555 0, 556 abs_tol=center_distance_atol 557 ) and maximum_radius - minimum_radius < circle['radius_spread']: 558 logging.debug('Replacing the previously found circle. ' 559 'Circle located at %s has a smaller radius spread ' 560 'than the previously found circle at %s. ' 561 'Current radius spread: %.2f, ' 562 'previous radius spread: %.2f', 563 new_circle_center, old_circle_center, 564 maximum_radius - minimum_radius, 565 circle['radius_spread']) 566 circle_contours.pop() 567 circle = {} 568 num_circles -= 1 569 circle_contours.append(contour) 570 571 # Populate circle dictionary 572 circle['x'] = shape['ctx'] 573 circle['y'] = shape['cty'] 574 circle['r'] = (shape['width'] + shape['height']) / 4 575 circle['w'] = float(shape['width']) 576 circle['h'] = float(shape['height']) 577 circle['x_offset'] = (shape['ctx'] - img_size[1]//2) / circle['w'] 578 circle['y_offset'] = (shape['cty'] - img_size[0]//2) / circle['h'] 579 circle['radius_spread'] = maximum_radius - minimum_radius 580 logging.debug('Num pts: %d', num_pts) 581 logging.debug('Aspect ratio: %.3f', aspect_ratio) 582 logging.debug('Circlish value: %.3f', circlish) 583 logging.debug('Location: %.1f x %.1f', circle['x'], circle['y']) 584 logging.debug('Radius: %.3f', circle['r']) 585 logging.debug('Circle center position wrt to image center: %.3fx%.3f', 586 circle['x_offset'], circle['y_offset']) 587 num_circles += 1 588 # if more than one circle found, break 589 if num_circles == 2: 590 break 591 592 if num_circles == 0: 593 image_processing_utils.write_image(img/255, img_name, True) 594 if not use_adaptive_threshold: 595 return find_circle( 596 img, img_name, min_area, color, use_adaptive_threshold=True) 597 else: 598 raise AssertionError('No circle detected. ' 599 'Please take pictures according to instructions.') 600 601 if num_circles > 1: 602 image_processing_utils.write_image(img/255, img_name, True) 603 cv2.drawContours(img, circle_contours, -1, CV2_RED, 604 CV2_LINE_THICKNESS) 605 img_name_parts = img_name.split('.') 606 image_processing_utils.write_image( 607 img/255, f'{img_name_parts[0]}_contours.{img_name_parts[1]}', True) 608 if not use_adaptive_threshold: 609 return find_circle( 610 img, img_name, min_area, color, use_adaptive_threshold=True) 611 raise AssertionError('More than 1 circle detected. ' 612 'Background of scene may be too complex.') 613 614 return circle 615 616 617def append_circle_center_to_img(circle, img, img_name, save_img=True): 618 """Append circle center and image center to image and save image. 619 620 Draws line from circle center to image center and then labels end-points. 621 Adjusts text positioning depending on circle center wrt image center. 622 Moves text position left/right half of up/down movement for visual aesthetics. 623 624 Args: 625 circle: dict with circle location vals. 626 img: numpy float image array in RGB, with pixel values in [0,255]. 627 img_name: string with image info of format and size. 628 save_img: optional boolean to not save image 629 """ 630 line_width_scaling_factor = 500 631 text_move_scaling_factor = 3 632 img_size = img.shape 633 img_center_x = img_size[1]//2 634 img_center_y = img_size[0]//2 635 636 # draw line from circle to image center 637 line_width = int(max(1, max(img_size)//line_width_scaling_factor)) 638 font_size = line_width // 2 639 move_text_dist = line_width * text_move_scaling_factor 640 cv2.line(img, (circle['x'], circle['y']), (img_center_x, img_center_y), 641 CV2_RED, line_width) 642 643 # adjust text location 644 move_text_right_circle = -1 645 move_text_right_image = 2 646 if circle['x'] > img_center_x: 647 move_text_right_circle = 2 648 move_text_right_image = -1 649 650 move_text_down_circle = -1 651 move_text_down_image = 4 652 if circle['y'] > img_center_y: 653 move_text_down_circle = 4 654 move_text_down_image = -1 655 656 # add circles to end points and label 657 radius_pt = line_width * 2 # makes a dot 2x line width 658 filled_pt = -1 # cv2 value for a filled circle 659 # circle center 660 cv2.circle(img, (circle['x'], circle['y']), radius_pt, CV2_RED, filled_pt) 661 text_circle_x = move_text_dist * move_text_right_circle + circle['x'] 662 text_circle_y = move_text_dist * move_text_down_circle + circle['y'] 663 cv2.putText(img, 'circle center', (text_circle_x, text_circle_y), 664 cv2.FONT_HERSHEY_SIMPLEX, font_size, CV2_RED, line_width) 665 # image center 666 cv2.circle(img, (img_center_x, img_center_y), radius_pt, CV2_RED, filled_pt) 667 text_imgct_x = move_text_dist * move_text_right_image + img_center_x 668 text_imgct_y = move_text_dist * move_text_down_image + img_center_y 669 cv2.putText(img, 'image center', (text_imgct_x, text_imgct_y), 670 cv2.FONT_HERSHEY_SIMPLEX, font_size, CV2_RED, line_width) 671 if save_img: 672 image_processing_utils.write_image(img/255, img_name, True) # [0, 1] values 673 674 675def is_circle_cropped(circle, size): 676 """Determine if a circle is cropped by edge of image. 677 678 Args: 679 circle: list [x, y, radius] of circle 680 size: tuple (x, y) of size of img 681 682 Returns: 683 Boolean True if selected circle is cropped 684 """ 685 686 cropped = False 687 circle_x, circle_y = circle[0], circle[1] 688 circle_r = circle[2] 689 x_min, x_max = circle_x - circle_r, circle_x + circle_r 690 y_min, y_max = circle_y - circle_r, circle_y + circle_r 691 if x_min < 0 or y_min < 0 or x_max > size[0] or y_max > size[1]: 692 cropped = True 693 return cropped 694 695 696def find_white_square(img, min_area): 697 """Find the white square in the test image. 698 699 Args: 700 img: numpy image array in RGB, with pixel values in [0,255]. 701 min_area: float of minimum area of circle to find 702 703 Returns: 704 square = {'left', 'right', 'top', 'bottom', 'width', 'height'} 705 """ 706 square = {} 707 num_squares = 0 708 img_size = img.shape 709 710 # convert to gray-scale image 711 img_gray = image_processing_utils.convert_rgb_to_grayscale(img) 712 713 # otsu threshold to binarize the image 714 img_bw = binarize_image(img_gray) 715 716 # find contours 717 contours = find_all_contours(img_bw) 718 719 # Check each contour and find the square bigger than min_area 720 logging.debug('Initial number of contours: %d', len(contours)) 721 min_area = img_size[0]*img_size[1]*min_area 722 logging.debug('min_area: %.3f', min_area) 723 for contour in contours: 724 area = cv2.contourArea(contour) 725 num_pts = len(contour) 726 if (area > min_area and num_pts >= 4): 727 shape = component_shape(contour) 728 squarish = (shape['width'] * shape['height']) / area 729 aspect_ratio = shape['width'] / shape['height'] 730 logging.debug('Potential square found. squarish: %.3f, ar: %.3f, pts: %d', 731 squarish, aspect_ratio, num_pts) 732 if (math.isclose(1.0, squarish, abs_tol=SQUARISH_RTOL) and 733 math.isclose(1.0, aspect_ratio, abs_tol=SQUARISH_AR_RTOL)): 734 # Populate square dictionary 735 angle = cv2.minAreaRect(contour)[-1] 736 if angle < -45: 737 angle += 90 738 square['angle'] = angle 739 square['left'] = shape['left'] - SQUARE_CROP_MARGIN 740 square['right'] = shape['right'] + SQUARE_CROP_MARGIN 741 square['top'] = shape['top'] - SQUARE_CROP_MARGIN 742 square['bottom'] = shape['bottom'] + SQUARE_CROP_MARGIN 743 square['w'] = shape['width'] + 2*SQUARE_CROP_MARGIN 744 square['h'] = shape['height'] + 2*SQUARE_CROP_MARGIN 745 num_squares += 1 746 747 if num_squares == 0: 748 raise AssertionError('No white square detected. ' 749 'Please take pictures according to instructions.') 750 if num_squares > 1: 751 raise AssertionError('More than 1 white square detected. ' 752 'Background of scene may be too complex.') 753 return square 754 755 756def get_angle(input_img): 757 """Computes anglular inclination of chessboard in input_img. 758 759 Args: 760 input_img (2D numpy.ndarray): Grayscale image stored as a 2D numpy array. 761 Returns: 762 Median angle of squares in degrees identified in the image. 763 764 Angle estimation algorithm description: 765 Input: 2D grayscale image of chessboard. 766 Output: Angle of rotation of chessboard perpendicular to 767 chessboard. Assumes chessboard and camera are parallel to 768 each other. 769 770 1) Use adaptive threshold to make image binary 771 2) Find countours 772 3) Filter out small contours 773 4) Filter out all non-square contours 774 5) Compute most common square shape. 775 The assumption here is that the most common square instances are the 776 chessboard squares. We've shown that with our current tuning, we can 777 robustly identify the squares on the sensor fusion chessboard. 778 6) Return median angle of most common square shape. 779 780 USAGE NOTE: This function has been tuned to work for the chessboard used in 781 the sensor_fusion tests. See images in test_images/rotated_chessboard/ for 782 sample captures. If this function is used with other chessboards, it may not 783 work as expected. 784 """ 785 # Tuning parameters 786 square_area_min = (float)(input_img.shape[1] * SQUARE_AREA_MIN_REL) 787 788 # Creates copy of image to avoid modifying original. 789 img = numpy.array(input_img, copy=True) 790 791 # Scale pixel values from 0-1 to 0-255 792 img_uint8 = image_processing_utils.convert_image_to_uint8(img) 793 img_thresh = cv2.adaptiveThreshold( 794 img_uint8, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 201, 2) 795 796 # Find all contours. 797 contours = find_all_contours(img_thresh) 798 799 # Filter contours to squares only. 800 square_contours = [] 801 for contour in contours: 802 rect = cv2.minAreaRect(contour) 803 _, (width, height), angle = rect 804 805 # Skip non-squares 806 if not math.isclose(width, height, rel_tol=SQUARE_TOL): 807 continue 808 809 # Remove very small contours: usually just tiny dots due to noise. 810 area = cv2.contourArea(contour) 811 if area < square_area_min: 812 continue 813 814 square_contours.append(contour) 815 816 areas = [] 817 for contour in square_contours: 818 area = cv2.contourArea(contour) 819 areas.append(area) 820 821 median_area = numpy.median(areas) 822 823 filtered_squares = [] 824 filtered_angles = [] 825 for square in square_contours: 826 area = cv2.contourArea(square) 827 if not math.isclose(area, median_area, rel_tol=SQUARE_TOL): 828 continue 829 830 filtered_squares.append(square) 831 _, (width, height), angle = cv2.minAreaRect(square) 832 filtered_angles.append(angle) 833 834 if len(filtered_angles) < ANGLE_NUM_MIN: 835 logging.debug( 836 'A frame had too few angles to be processed. ' 837 'Num of angles: %d, MIN: %d', len(filtered_angles), ANGLE_NUM_MIN) 838 return None 839 840 return numpy.median(filtered_angles) 841 842 843def correct_faces_for_crop(faces, img, crop): 844 """Correct face rectangles for sensor crop. 845 846 Args: 847 faces: list of dicts with face information relative to sensor's 848 aspect ratio 849 img: np image array 850 crop: dict of crop region size with 'top', 'right', 'left', 'bottom' 851 as keys to desired region of the sensor to read out 852 Returns: 853 list of face locations (left, right, top, bottom) corrected 854 """ 855 faces_corrected = [] 856 crop_w = crop['right'] - crop['left'] 857 crop_h = crop['bottom'] - crop['top'] 858 logging.debug('crop region: %s', str(crop)) 859 img_w, img_h = img.shape[1], img.shape[0] 860 crop_aspect_ratio = crop_w / crop_h 861 img_aspect_ratio = img_w / img_h 862 for rect in [face['bounds'] for face in faces]: 863 logging.debug('rect: %s', str(rect)) 864 if crop_aspect_ratio >= img_aspect_ratio: 865 # Sensor width is being cropped, so we need to adjust the horizontal 866 # coordinates of the face rectangles to account for the crop. 867 # Since we are converting from sensor coordinates to image coordinates 868 img_crop_h_ratio = img_h / crop_h 869 scaled_crop_w = crop_w * img_crop_h_ratio 870 excess_w = (img_w - scaled_crop_w) / 2 871 left = int( 872 round((rect['left'] - crop['left']) * img_crop_h_ratio + excess_w)) 873 right = int( 874 round((rect['right'] - crop['left']) * img_crop_h_ratio + excess_w)) 875 top = int(round((rect['top'] - crop['top']) * img_crop_h_ratio)) 876 bottom = int(round((rect['bottom'] - crop['top']) * img_crop_h_ratio)) 877 else: 878 # Sensor height is being cropped, so we need to adjust the vertical 879 # coordinates of the face rectangles to account for the crop. 880 img_crop_w_ratio = img_w / crop_w 881 scaled_crop_h = crop_h * img_crop_w_ratio 882 excess_w = (img_h - scaled_crop_h) / 2 883 left = int(round((rect['left'] - crop['left']) * img_crop_w_ratio)) 884 right = int(round((rect['right'] - crop['left']) * img_crop_w_ratio)) 885 top = int( 886 round((rect['top'] - crop['top']) * img_crop_w_ratio + excess_w)) 887 bottom = int( 888 round((rect['bottom'] - crop['top']) * img_crop_w_ratio + excess_w)) 889 faces_corrected.append([left, right, top, bottom]) 890 logging.debug('faces_corrected: %s', str(faces_corrected)) 891 return faces_corrected 892 893 894def eliminate_duplicate_centers(coordinates_list): 895 """Checks center coordinates of OpenCV's face rectangles. 896 897 Method makes sure that the list of face rectangles' centers do not 898 contain duplicates from the same face 899 900 Args: 901 coordinates_list: list; coordinates of face rectangles' centers 902 Returns: 903 non_duplicate_list: list; coordinates of face rectangles' centers 904 without duplicates on the same face 905 """ 906 output = set() 907 908 for _, xy1 in enumerate(coordinates_list): 909 for _, xy2 in enumerate(coordinates_list): 910 if scipy.spatial.distance.euclidean(xy1, xy2) < FACE_MIN_CENTER_DELTA: 911 continue 912 if xy1 not in output: 913 output.add(xy1) 914 else: 915 output.add(xy2) 916 return list(output) 917 918 919def match_face_locations(faces_cropped, faces_opencv, img, img_name): 920 """Assert face locations between two methods. 921 922 Method determines if center of opencv face boxes is within face detection 923 face boxes. Using math.hypot to measure the distance between the centers, 924 as math.dist is not available for python versions before 3.8. 925 926 Args: 927 faces_cropped: list of lists with (l, r, t, b) for each face. 928 faces_opencv: list of lists with (x, y, w, h) for each face. 929 img: numpy [0, 1] image array 930 img_name: text string with path to image file 931 """ 932 # turn faces_opencv into list of center locations 933 faces_opencv_center = [(x+w//2, y+h//2) for (x, y, w, h) in faces_opencv] 934 cropped_faces_centers = [ 935 ((l+r)//2, (t+b)//2) for (l, r, t, b) in faces_cropped] 936 faces_opencv_center.sort(key=lambda t: [t[1], t[0]]) 937 cropped_faces_centers.sort(key=lambda t: [t[1], t[0]]) 938 logging.debug('cropped face centers: %s', str(cropped_faces_centers)) 939 logging.debug('opencv face center: %s', str(faces_opencv_center)) 940 faces_opencv_centers = [] 941 num_centers_aligned = 0 942 943 # eliminate duplicate openCV face rectangles' centers the same face 944 faces_opencv_centers = eliminate_duplicate_centers(faces_opencv_center) 945 logging.debug('opencv face centers: %s', str(faces_opencv_centers)) 946 947 for (x, y) in faces_opencv_centers: 948 for (x1, y1) in cropped_faces_centers: 949 centers_dist = math.hypot(x-x1, y-y1) 950 if centers_dist < FACE_CENTER_MIN_LOGGING_DIST: 951 logging.debug('centers_dist: %.3f', centers_dist) 952 if (abs(x-x1) < FACE_CENTER_MATCH_TOL_X and 953 abs(y-y1) < FACE_CENTER_MATCH_TOL_Y): 954 num_centers_aligned += 1 955 956 # If test failed, save image with green AND OpenCV red rectangles 957 image_processing_utils.write_image(img, img_name) 958 if num_centers_aligned < FACES_ALIGNED_MIN_NUM: 959 for (x, y, w, h) in faces_opencv: 960 cv2.rectangle(img, (x, y), (x+w, y+h), CV2_RED_NORM, 2) 961 image_processing_utils.write_image(img, img_name) 962 logging.debug('centered: %s', str(num_centers_aligned)) 963 raise AssertionError(f'Face rectangles in wrong location(s)!. ' 964 f'Found {num_centers_aligned} rectangles near cropped ' 965 f'face centers, expected {FACES_ALIGNED_MIN_NUM}') 966 967 968def draw_green_boxes_around_faces(img, faces_cropped, img_name): 969 """Correct face rectangles for sensor crop. 970 971 Args: 972 img: numpy [0, 1] image array 973 faces_cropped: list of lists with (l, r, t, b) for each face 974 img_name: text string with path to image file 975 Returns: 976 image with green rectangles 977 """ 978 # draw boxes around faces in green and save image 979 for (l, r, t, b) in faces_cropped: 980 cv2.rectangle(img, (l, t), (r, b), CV2_GREEN_NORM, 2) 981 image_processing_utils.write_image(img, img_name) 982 983 984def version_agnostic_detect_markers(image): 985 """Detects ArUco markers with compatibility across cv2 versions. 986 987 Args: 988 image: numpy image in BGR channel order with ArUco markers to be detected. 989 Returns: 990 corners: list of detected corners. 991 ids: list of int ids for each ArUco markers in the input_img. 992 rejected_params: list of rejected corners. 993 """ 994 # ArUco markers used are 4x4 995 aruco_dict = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_100) 996 parameters = cv2.aruco.DetectorParameters() 997 aruco_detector = None 998 if hasattr(cv2.aruco, ARUCO_DETECTOR_ATTRIBUTE_NAME): 999 aruco_detector = cv2.aruco.ArucoDetector(aruco_dict, parameters) 1000 # Use ArucoDetector object if available, else fall back to detectMarkers() 1001 if aruco_detector is not None: 1002 return aruco_detector.detectMarkers(image) 1003 else: 1004 return cv2.aruco.detectMarkers( 1005 image, aruco_dict, parameters=parameters 1006 ) 1007 1008 1009def find_aruco_markers( 1010 input_img, output_img_path, aruco_marker_count=ARUCO_CORNER_COUNT, 1011 force_greyscale=False): 1012 """Detects ArUco markers in the input_img. 1013 1014 Finds ArUco markers in the input_img and draws the contours 1015 around them. 1016 Args: 1017 input_img: input img in numpy array with ArUco markers 1018 to be detected 1019 output_img_path: path of the image to be saved with contours 1020 around the markers detected 1021 aruco_marker_count: optional int for minimum markers to expect. 1022 force_greyscale: optional bool to force greyscale detection, even if enough 1023 markers are detected. 1024 Returns: 1025 corners: list of detected corners 1026 ids: list of int ids for each ArUco markers in the input_img 1027 rejected_params: list of rejected corners 1028 """ 1029 corners, ids, rejected_params = version_agnostic_detect_markers(input_img) 1030 # Early return if sufficient markers found and greyscale detection not needed 1031 if ids is not None and len(ids) >= aruco_marker_count and not force_greyscale: 1032 logging.debug('All ArUco markers detected.') 1033 cv2.aruco.drawDetectedMarkers(input_img, corners, ids) 1034 image_processing_utils.write_image(input_img / 255, output_img_path) 1035 return corners, ids, rejected_params 1036 # Try with high-contrast greyscale if needed 1037 logging.debug('Trying ArUco marker detection with greyscale image.') 1038 bw_img = convert_image_to_high_contrast_black_white(input_img) 1039 corners, ids, rejected_params = version_agnostic_detect_markers(bw_img) 1040 if ids is not None and len(ids) >= aruco_marker_count: 1041 logging.debug('All ArUco markers detected with greyscale image.') 1042 # Handle case where no markers are found 1043 if ids is None: 1044 image_processing_utils.write_image(input_img/255, output_img_path) 1045 raise AssertionError('ArUco markers not detected.') 1046 # Log and save results 1047 logging.debug('Number of ArUco markers detected w/ greyscale: %d', len(ids)) 1048 logging.debug('IDs of the ArUco markers detected: %s', ids) 1049 logging.debug('Corners of the ArUco markers detected: %s', corners) 1050 cv2.aruco.drawDetectedMarkers(bw_img, corners, ids) 1051 image_processing_utils.write_image(bw_img / 255, output_img_path) 1052 return corners, ids, rejected_params 1053 1054 1055def get_patch_from_aruco_markers( 1056 input_img, aruco_marker_corners, aruco_marker_ids): 1057 """Returns the rectangle patch from the aruco marker corners. 1058 1059 Note: Refer to image used in scene7 for ArUco markers location. 1060 1061 Args: 1062 input_img: input img in numpy array with ArUco markers 1063 to be detected 1064 aruco_marker_corners: array of aruco marker corner coordinates detected by 1065 opencv_processing_utils.find_aruco_markers 1066 aruco_marker_ids: array of ids of aruco markers detected by 1067 opencv_processing_utils.find_aruco_markers 1068 Returns: 1069 Numpy float image array of the rectangle patch 1070 """ 1071 outer_rect_coordinates = {} 1072 for corner, marker_id in zip(aruco_marker_corners, aruco_marker_ids): 1073 corner = corner.reshape(4, 2) # opencv returns 3D array 1074 index = marker_id[0] 1075 # Roll the array 4x to align with the coordinates of the corner adjacent 1076 # to the corner of the rectangle 1077 # Marker id: 0 => index 2 coordinates 1078 # Marker id: 1 => index 3 coordinates 1079 # Marker id: 2 => index 0 coordinates 1080 # Marker id: 3 => index 1 coordinates 1081 corner = numpy.roll(corner, 4) 1082 1083 outer_rect_coordinates[index] = tuple(corner[index]) 1084 1085 red_corner = tuple(map(int, outer_rect_coordinates[0])) 1086 green_corner = tuple(map(int, outer_rect_coordinates[1])) 1087 gray_corner = tuple(map(int, outer_rect_coordinates[2])) 1088 blue_corner = tuple(map(int, outer_rect_coordinates[3])) 1089 1090 logging.debug('red_corner: %s', red_corner) 1091 logging.debug('blue_corner: %s', blue_corner) 1092 logging.debug('green_corner: %s', green_corner) 1093 logging.debug('gray_corner: %s', gray_corner) 1094 # Ensure that the image is not rotated 1095 blue_gray_y_diff = abs(gray_corner[1] - blue_corner[1]) 1096 red_green_y_diff = abs(green_corner[1] - red_corner[1]) 1097 1098 if ((blue_gray_y_diff > IMAGE_ROTATION_THRESHOLD) or 1099 (red_green_y_diff > IMAGE_ROTATION_THRESHOLD)): 1100 raise AssertionError('Image rotation is not within the threshold. ' 1101 f'Actual blue_gray_y_diff: {blue_gray_y_diff}, ' 1102 f'red_green_y_diff: {red_green_y_diff} ' 1103 f'Expected {IMAGE_ROTATION_THRESHOLD}') 1104 cv2.rectangle(input_img, red_corner, gray_corner, 1105 CV2_RED_NORM, CV2_LINE_THICKNESS) 1106 return input_img[red_corner[1]:gray_corner[1], 1107 red_corner[0]:gray_corner[0]].copy() 1108 1109 1110def get_chart_boundary_from_aruco_markers( 1111 aruco_marker_corners, aruco_marker_ids, input_img, output_img_path): 1112 """Returns top left and bottom right coordinates from the aruco markers. 1113 1114 Note: Refer to image used in scene8 for ArUco markers location. 1115 1116 Args: 1117 aruco_marker_corners: array of aruco marker corner coordinates detected by 1118 opencv_processing_utils.find_aruco_markers. 1119 aruco_marker_ids: array of ids of aruco markers detected by 1120 opencv_processing_utils.find_aruco_markers. 1121 input_img: 3D RGB numpy [0, 255] uint8; input image. 1122 output_img_path: string; output image path. 1123 Returns: 1124 top_left: tuple; aruco marker corner coordinates in pixel. 1125 top_right: tuple; aruco marker corner coordinates in pixel. 1126 bottom_right: tuple; aruco marker corner coordinates in pixel. 1127 bottom_left: tuple; aruco marker corner coordinates in pixel. 1128 """ 1129 outer_rect_coordinates = {} 1130 for corner, marker_id in zip(aruco_marker_corners, aruco_marker_ids): 1131 corner = corner.reshape(4, 2) # reshape opencv 3D array to 4x2 1132 index = marker_id[0] 1133 corner = numpy.roll(corner, ARUCO_CORNER_COUNT) 1134 outer_rect_coordinates[index] = tuple(corner[index]) 1135 logging.debug('Corners: %s', corner) 1136 logging.debug('Index: %s', index) 1137 logging.debug('Outer rect coordinates: %s', outer_rect_coordinates[index]) 1138 top_left = tuple(map(int, outer_rect_coordinates[0])) 1139 top_right = tuple(map(int, outer_rect_coordinates[1])) 1140 bottom_right = tuple(map(int, outer_rect_coordinates[2])) 1141 bottom_left = tuple(map(int, outer_rect_coordinates[3])) 1142 1143 # Outline metering rectangles with corresponding colors 1144 rect_w = round((bottom_right[0] - top_left[0])/NUM_AE_AWB_REGIONS) 1145 top_x, top_y = top_left[0], top_left[1] 1146 bottom_x, bottom_y = bottom_left[0], bottom_left[1] 1147 cv2.rectangle( 1148 input_img, 1149 (top_x, top_y), (bottom_x + rect_w, bottom_y), 1150 CV2_BLUE, CV2_LINE_THICKNESS) 1151 cv2.rectangle( 1152 input_img, 1153 (top_x + rect_w, top_y), (bottom_x + rect_w * 2, bottom_y), 1154 CV2_WHITE, CV2_LINE_THICKNESS) 1155 cv2.rectangle( 1156 input_img, 1157 (top_x + rect_w * 2, top_y), (bottom_x + rect_w * 3, bottom_y), 1158 CV2_BLACK, CV2_LINE_THICKNESS) 1159 cv2.rectangle( 1160 input_img, 1161 (top_x + rect_w * 3, top_y), bottom_right, 1162 CV2_YELLOW, CV2_LINE_THICKNESS) 1163 image_processing_utils.write_image(input_img/255, output_img_path) 1164 logging.debug('ArUco marker top_left: %s', top_left) 1165 logging.debug('ArUco marker bottom_right: %s', bottom_right) 1166 return top_left, top_right, bottom_right, bottom_left 1167 1168 1169def get_aruco_center(corners): 1170 """Get the center of an ArUco marker defined by its four corners. 1171 1172 Args: 1173 corners: list of 4 Iterables, each Iterable is a (x, y) corner coordinate. 1174 Returns: 1175 x, y: the x, y coordinates of the center of the ArUco marker. 1176 """ 1177 x = (corners[0][0] + corners[2][0]) // 2 # mean of top left x, bottom right x 1178 y = (corners[1][1] + corners[3][1]) // 2 # mean of top right y, bottom left y 1179 return x, y 1180 1181 1182def get_aruco_marker_side_length(corners): 1183 """Get the side length of an ArUco marker defined by its four corners. 1184 1185 This method uses the x-distance from the top left corner to the 1186 bottom right corner and the y-distance from the top right corner to the 1187 bottom left corner to calculate the side length of the ArUco marker. 1188 1189 Args: 1190 corners: list of 4 Iterables, each Iterable is a (x, y) corner coordinate. 1191 Returns: 1192 The side length of the ArUco marker. 1193 """ 1194 return math.sqrt( 1195 (corners[2][0] - corners[0][0]) * (corners[3][1] - corners[1][1]) 1196 ) 1197 1198 1199def _mark_aruco_image(img, data): 1200 """Return marked image with ArUco marker center and image center. 1201 1202 Args: 1203 img: NumPy image in BGR channel order. 1204 data: zoom_capture_utils.ZoomTestData corresponding to the image. 1205 """ 1206 center_x, center_y = get_aruco_center( 1207 data.aruco_corners) 1208 # Mark ArUco marker center 1209 img = cv2.drawMarker( 1210 img, (int(center_x), int(center_y)), 1211 color=CV2_GREEN, markerType=cv2.MARKER_TILTED_CROSS, 1212 markerSize=CV2_ZOOM_MARKER_SIZE, thickness=CV2_ZOOM_MARKER_THICKNESS) 1213 # Mark ArUco marker edges 1214 # TODO: b/369852004 - make side length discrepancies more visible 1215 for line_start, line_end in zip( 1216 data.aruco_corners, 1217 numpy.vstack((data.aruco_corners[1:], data.aruco_corners[0]))): 1218 img = cv2.line( 1219 img, 1220 (int(line_start[0]), int(line_start[1])), 1221 (int(line_end[0]), int(line_end[1])), 1222 color=CV2_BLUE, 1223 thickness=CV2_ZOOM_MARKER_THICKNESS) 1224 # Mark image center 1225 m_x, m_y = img.shape[1] // 2, img.shape[0] // 2 1226 img = cv2.drawMarker(img, (m_x, m_y), 1227 color=CV2_BLUE, markerType=cv2.MARKER_CROSS, 1228 markerSize=CV2_ZOOM_MARKER_SIZE, 1229 thickness=CV2_ZOOM_MARKER_THICKNESS) 1230 return img 1231 1232 1233def mark_zoom_images(images, test_data, img_name_stem): 1234 """Mark chosen ArUco marker's center and center of image for all test images. 1235 1236 Args: 1237 images: BGR images in uint8, [0, 255] format. 1238 test_data: Iterable[zoom_capture_utils.ZoomTestData]. 1239 img_name_stem: str, beginning of path to save data. 1240 """ 1241 for img, data in zip(images, test_data): 1242 img = _mark_aruco_image(img, data) 1243 img_name = (f'{img_name_stem}_{data.result_zoom:.2f}_marked.jpg') 1244 cv2.imwrite(img_name, img) 1245 1246 1247def mark_zoom_images_to_video(out, image_paths, test_data): 1248 """Mark chosen ArUco marker's center and image center, then write to video. 1249 1250 Args: 1251 out: VideoWriter to write frames to. 1252 image_paths: Iterable[str] of images paths of the frames 1253 test_data: Iterable[zoom_capture_utils.ZoomTestData]. 1254 """ 1255 for image_path, data in zip(image_paths, test_data): 1256 img = cv2.imread(image_path) 1257 img = _mark_aruco_image(img, data) 1258 out.write(img) 1259 1260 1261def define_metering_rectangle_values( 1262 props, top_left, top_right, bottom_right, bottom_left, w, h): 1263 """Find normalized values of coordinates and return 4 metering rects. 1264 1265 Args: 1266 props: dict; camera properties object. 1267 top_left: coordinates; defined by aruco markers for targeted image. 1268 top_right: coordinates; defined by aruco markers for targeted image. 1269 bottom_right: coordinates; defined by aruco markers for targeted image. 1270 bottom_left: coordinates; defined by aruco markers for targeted image. 1271 w: int; active array width in pixels. 1272 h: int; active array height in pixels. 1273 Returns: 1274 meter_rects: 4 metering rectangles made of (x, y, width, height, weight). 1275 x, y are the top left coordinate of the metering rectangle. 1276 """ 1277 # If testing front camera, mirror coordinates either left/right or up/down 1278 # Preview are flipped on device's natural orientation 1279 # For sensor orientation 90 or 270, it is up or down 1280 # For sensor orientation 0 or 180, it is left or right 1281 if (props['android.lens.facing'] == 1282 camera_properties_utils.LENS_FACING['FRONT']): 1283 if props['android.sensor.orientation'] in (90, 270): 1284 tl_coordinates = (bottom_left[0], h - bottom_left[1]) 1285 br_coordinates = (top_right[0], h - top_right[1]) 1286 logging.debug('Found sensor orientation %d, flipping up down', 1287 props['android.sensor.orientation']) 1288 else: 1289 tl_coordinates = (w - top_right[0], top_right[1]) 1290 br_coordinates = (w - bottom_left[0], bottom_left[1]) 1291 logging.debug('Found sensor orientation %d, flipping left right', 1292 props['android.sensor.orientation']) 1293 logging.debug('Mirrored top-left coordinates: %s', tl_coordinates) 1294 logging.debug('Mirrored bottom-right coordinates: %s', br_coordinates) 1295 else: 1296 tl_coordinates, br_coordinates = top_left, bottom_right 1297 1298 # Normalize coordinates' values to construct metering rectangles 1299 meter_rects = [] 1300 tl_normalized_x = tl_coordinates[0] / w 1301 tl_normalized_y = tl_coordinates[1] / h 1302 br_normalized_x = br_coordinates[0] / w 1303 br_normalized_y = br_coordinates[1] / h 1304 rect_w = round((br_normalized_x - tl_normalized_x) / NUM_AE_AWB_REGIONS, 2) 1305 rect_h = round(br_normalized_y - tl_normalized_y, 2) 1306 for i in range(NUM_AE_AWB_REGIONS): 1307 x = round(tl_normalized_x + (rect_w * i), 2) 1308 y = round(tl_normalized_y, 2) 1309 meter_rect = [x, y, rect_w, rect_h, AE_AWB_METER_WEIGHT] 1310 meter_rects.append(meter_rect) 1311 logging.debug('metering rects: %s', meter_rects) 1312 return meter_rects 1313 1314 1315def convert_image_to_high_contrast_black_white( 1316 img, contrast=CV2_CONTRAST_ALPHA, brightness=CV2_CONTRAST_BETA): 1317 """Convert capture to high contrast black and white image. 1318 1319 Args: 1320 img: numpy array of image. 1321 contrast: gain parameter between the value of 0 to 3. 1322 brightness: bias parameter between the value of 1 to 100. 1323 Returns: 1324 high_contrast_img: high contrast black and white image. 1325 """ 1326 copy_img = numpy.ndarray.copy(img) 1327 uint8_img = image_processing_utils.convert_image_to_uint8(copy_img) 1328 gray_img = convert_to_y(uint8_img) 1329 img_bw = cv2.convertScaleAbs( 1330 gray_img, alpha=contrast, beta=brightness) 1331 _, high_contrast_img = cv2.threshold( 1332 numpy.uint8(img_bw), CV2_THESHOLD_LOWER_BLACK, CH_FULL_SCALE, 1333 cv2.THRESH_BINARY + cv2.THRESH_OTSU 1334 ) 1335 high_contrast_img = numpy.expand_dims( 1336 (CH_FULL_SCALE - high_contrast_img), axis=2) 1337 return high_contrast_img 1338