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