• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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