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