xref: /aosp_15_r20/cts/apps/CameraITS/utils/zoom_capture_utils.py (revision b7c941bb3fa97aba169d73cee0bed2de8ac964bf)
1*b7c941bbSAndroid Build Coastguard Worker# Copyright 2023 The Android Open Source Project
2*b7c941bbSAndroid Build Coastguard Worker#
3*b7c941bbSAndroid Build Coastguard Worker# Licensed under the Apache License, Version 2.0 (the "License");
4*b7c941bbSAndroid Build Coastguard Worker# you may not use this file except in compliance with the License.
5*b7c941bbSAndroid Build Coastguard Worker# You may obtain a copy of the License at
6*b7c941bbSAndroid Build Coastguard Worker#
7*b7c941bbSAndroid Build Coastguard Worker#      http://www.apache.org/licenses/LICENSE-2.0
8*b7c941bbSAndroid Build Coastguard Worker#
9*b7c941bbSAndroid Build Coastguard Worker# Unless required by applicable law or agreed to in writing, software
10*b7c941bbSAndroid Build Coastguard Worker# distributed under the License is distributed on an "AS IS" BASIS,
11*b7c941bbSAndroid Build Coastguard Worker# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12*b7c941bbSAndroid Build Coastguard Worker# See the License for the specific language governing permissions and
13*b7c941bbSAndroid Build Coastguard Worker# limitations under the License.
14*b7c941bbSAndroid Build Coastguard Worker"""Utility functions for zoom capture.
15*b7c941bbSAndroid Build Coastguard Worker"""
16*b7c941bbSAndroid Build Coastguard Worker
17*b7c941bbSAndroid Build Coastguard Workerfrom collections.abc import Iterable
18*b7c941bbSAndroid Build Coastguard Workerimport dataclasses
19*b7c941bbSAndroid Build Coastguard Workerimport logging
20*b7c941bbSAndroid Build Coastguard Workerimport math
21*b7c941bbSAndroid Build Coastguard Workerfrom typing import Optional
22*b7c941bbSAndroid Build Coastguard Workerimport cv2
23*b7c941bbSAndroid Build Coastguard Workerfrom matplotlib import animation
24*b7c941bbSAndroid Build Coastguard Workerfrom matplotlib import ticker
25*b7c941bbSAndroid Build Coastguard Workerimport matplotlib.pyplot as plt
26*b7c941bbSAndroid Build Coastguard Workerimport numpy
27*b7c941bbSAndroid Build Coastguard Workerfrom PIL import Image
28*b7c941bbSAndroid Build Coastguard Worker
29*b7c941bbSAndroid Build Coastguard Workerimport camera_properties_utils
30*b7c941bbSAndroid Build Coastguard Workerimport capture_request_utils
31*b7c941bbSAndroid Build Coastguard Workerimport image_processing_utils
32*b7c941bbSAndroid Build Coastguard Workerimport opencv_processing_utils
33*b7c941bbSAndroid Build Coastguard Worker
34*b7c941bbSAndroid Build Coastguard Worker_CIRCLE_COLOR = 0  # [0: black, 255: white]
35*b7c941bbSAndroid Build Coastguard Worker_CIRCLE_AR_RTOL = 0.15  # contour width vs height (aspect ratio)
36*b7c941bbSAndroid Build Coastguard Worker_SMOOTH_ZOOM_OFFSET_MONOTONICITY_ATOL = 25  # number of pixels
37*b7c941bbSAndroid Build Coastguard Worker_CIRCLISH_RTOL = 0.05  # contour area vs ideal circle area pi*((w+h)/4)**2
38*b7c941bbSAndroid Build Coastguard Worker_CONTOUR_AREA_LOGGING_THRESH = 0.8  # logging tol to cut down spam in log file
39*b7c941bbSAndroid Build Coastguard Worker_CV2_LINE_THICKNESS = 3  # line thickness for drawing on images
40*b7c941bbSAndroid Build Coastguard Worker_CV2_RED = (255, 0, 0)  # color in cv2 to draw lines
41*b7c941bbSAndroid Build Coastguard Worker_MIN_AREA_RATIO = 0.00013  # Found empirically with partners
42*b7c941bbSAndroid Build Coastguard Worker_MIN_CIRCLE_PTS = 25
43*b7c941bbSAndroid Build Coastguard Worker_MIN_FOCUS_DIST_TOL = 0.80  # allow charts a little closer than min
44*b7c941bbSAndroid Build Coastguard Worker_OFFSET_ATOL = 10  # number of pixels
45*b7c941bbSAndroid Build Coastguard Worker_OFFSET_PLOT_FPS = 2
46*b7c941bbSAndroid Build Coastguard Worker_OFFSET_PLOT_INTERVAL = 400  # delay between frames in milliseconds.
47*b7c941bbSAndroid Build Coastguard Worker_OFFSET_RTOL_MIN_FD = 0.30
48*b7c941bbSAndroid Build Coastguard Worker_RADIUS_RTOL_MIN_FD = 0.15
49*b7c941bbSAndroid Build Coastguard Worker
50*b7c941bbSAndroid Build Coastguard WorkerDEFAULT_FOV_RATIO = 1  # ratio of sub camera's fov over logical camera's fov
51*b7c941bbSAndroid Build Coastguard WorkerJPEG_STR = 'jpg'
52*b7c941bbSAndroid Build Coastguard WorkerOFFSET_RTOL = 0.15
53*b7c941bbSAndroid Build Coastguard WorkerOFFSET_RTOL_SMOOTH_ZOOM = 0.5  # generous RTOL paired with other offset checks
54*b7c941bbSAndroid Build Coastguard WorkerPREFERRED_BASE_ZOOM_RATIO = 1  # Preferred base image for zoom data verification
55*b7c941bbSAndroid Build Coastguard WorkerPREFERRED_BASE_ZOOM_RATIO_RTOL = 0.1
56*b7c941bbSAndroid Build Coastguard WorkerPRV_Z_RTOL = 0.02  # 2% variation of zoom ratio between request and result
57*b7c941bbSAndroid Build Coastguard WorkerRADIUS_RTOL = 0.15
58*b7c941bbSAndroid Build Coastguard WorkerZOOM_MAX_THRESH = 9.0  # TODO: b/368666244 - reduce marker size and use 10.0
59*b7c941bbSAndroid Build Coastguard WorkerZOOM_MIN_THRESH = 2.0
60*b7c941bbSAndroid Build Coastguard WorkerZOOM_RTOL = 0.01  # variation of zoom ratio due to floating point
61*b7c941bbSAndroid Build Coastguard Worker
62*b7c941bbSAndroid Build Coastguard Worker
63*b7c941bbSAndroid Build Coastguard Worker@dataclasses.dataclass
64*b7c941bbSAndroid Build Coastguard Workerclass ZoomTestData:
65*b7c941bbSAndroid Build Coastguard Worker  """Class to store zoom-related metadata for a capture."""
66*b7c941bbSAndroid Build Coastguard Worker  result_zoom: float
67*b7c941bbSAndroid Build Coastguard Worker  radius_tol: float
68*b7c941bbSAndroid Build Coastguard Worker  offset_tol: float
69*b7c941bbSAndroid Build Coastguard Worker  focal_length: Optional[float] = None
70*b7c941bbSAndroid Build Coastguard Worker  # (x, y) coordinates of ArUco marker corners in clockwise order from top left.
71*b7c941bbSAndroid Build Coastguard Worker  aruco_corners: Optional[Iterable[float]] = None
72*b7c941bbSAndroid Build Coastguard Worker  aruco_offset: Optional[float] = None
73*b7c941bbSAndroid Build Coastguard Worker  physical_id: int = dataclasses.field(default=None)
74*b7c941bbSAndroid Build Coastguard Worker
75*b7c941bbSAndroid Build Coastguard Worker
76*b7c941bbSAndroid Build Coastguard Workerdef get_test_tols_and_cap_size(cam, props, chart_distance, debug):
77*b7c941bbSAndroid Build Coastguard Worker  """Determine the tolerance per camera based on test rig and camera params.
78*b7c941bbSAndroid Build Coastguard Worker
79*b7c941bbSAndroid Build Coastguard Worker  Cameras are pre-filtered to only include supportable cameras.
80*b7c941bbSAndroid Build Coastguard Worker  Supportable cameras are: YUV(RGB)
81*b7c941bbSAndroid Build Coastguard Worker
82*b7c941bbSAndroid Build Coastguard Worker  Args:
83*b7c941bbSAndroid Build Coastguard Worker    cam: camera object
84*b7c941bbSAndroid Build Coastguard Worker    props: dict; physical camera properties dictionary
85*b7c941bbSAndroid Build Coastguard Worker    chart_distance: float; distance to chart in cm
86*b7c941bbSAndroid Build Coastguard Worker    debug: boolean; log additional data
87*b7c941bbSAndroid Build Coastguard Worker
88*b7c941bbSAndroid Build Coastguard Worker  Returns:
89*b7c941bbSAndroid Build Coastguard Worker    dict of TOLs with camera focal length as key
90*b7c941bbSAndroid Build Coastguard Worker    largest common size across all cameras
91*b7c941bbSAndroid Build Coastguard Worker  """
92*b7c941bbSAndroid Build Coastguard Worker  ids = camera_properties_utils.logical_multi_camera_physical_ids(props)
93*b7c941bbSAndroid Build Coastguard Worker  physical_props = {}
94*b7c941bbSAndroid Build Coastguard Worker  physical_ids = []
95*b7c941bbSAndroid Build Coastguard Worker  for i in ids:
96*b7c941bbSAndroid Build Coastguard Worker    physical_props[i] = cam.get_camera_properties_by_id(i)
97*b7c941bbSAndroid Build Coastguard Worker    # find YUV capable physical cameras
98*b7c941bbSAndroid Build Coastguard Worker    if camera_properties_utils.backward_compatible(physical_props[i]):
99*b7c941bbSAndroid Build Coastguard Worker      physical_ids.append(i)
100*b7c941bbSAndroid Build Coastguard Worker
101*b7c941bbSAndroid Build Coastguard Worker  # find physical camera focal lengths that work well with rig
102*b7c941bbSAndroid Build Coastguard Worker  chart_distance_m = abs(chart_distance)/100  # convert CM to M
103*b7c941bbSAndroid Build Coastguard Worker  test_tols = {}
104*b7c941bbSAndroid Build Coastguard Worker  test_yuv_sizes = []
105*b7c941bbSAndroid Build Coastguard Worker  for i in physical_ids:
106*b7c941bbSAndroid Build Coastguard Worker    yuv_sizes = capture_request_utils.get_available_output_sizes(
107*b7c941bbSAndroid Build Coastguard Worker        'yuv', physical_props[i])
108*b7c941bbSAndroid Build Coastguard Worker    test_yuv_sizes.append(yuv_sizes)
109*b7c941bbSAndroid Build Coastguard Worker    if debug:
110*b7c941bbSAndroid Build Coastguard Worker      logging.debug('cam[%s] yuv sizes: %s', i, str(yuv_sizes))
111*b7c941bbSAndroid Build Coastguard Worker
112*b7c941bbSAndroid Build Coastguard Worker    # determine if minimum focus distance is less than rig depth
113*b7c941bbSAndroid Build Coastguard Worker    min_fd = physical_props[i]['android.lens.info.minimumFocusDistance']
114*b7c941bbSAndroid Build Coastguard Worker    for fl in physical_props[i]['android.lens.info.availableFocalLengths']:
115*b7c941bbSAndroid Build Coastguard Worker      logging.debug('cam[%s] min_fd: %.3f (diopters), fl: %.2f', i, min_fd, fl)
116*b7c941bbSAndroid Build Coastguard Worker      if (math.isclose(min_fd, 0.0, rel_tol=1E-6) or  # fixed focus
117*b7c941bbSAndroid Build Coastguard Worker          (1.0/min_fd < chart_distance_m*_MIN_FOCUS_DIST_TOL)):
118*b7c941bbSAndroid Build Coastguard Worker        test_tols[fl] = (RADIUS_RTOL, OFFSET_RTOL)
119*b7c941bbSAndroid Build Coastguard Worker      else:
120*b7c941bbSAndroid Build Coastguard Worker        test_tols[fl] = (_RADIUS_RTOL_MIN_FD, _OFFSET_RTOL_MIN_FD)
121*b7c941bbSAndroid Build Coastguard Worker        logging.debug('loosening RTOL for cam[%s]: '
122*b7c941bbSAndroid Build Coastguard Worker                      'min focus distance too large.', i)
123*b7c941bbSAndroid Build Coastguard Worker  # find intersection of formats for max common format
124*b7c941bbSAndroid Build Coastguard Worker  common_sizes = list(set.intersection(*[set(list) for list in test_yuv_sizes]))
125*b7c941bbSAndroid Build Coastguard Worker  if debug:
126*b7c941bbSAndroid Build Coastguard Worker    logging.debug('common_fmt: %s', max(common_sizes))
127*b7c941bbSAndroid Build Coastguard Worker
128*b7c941bbSAndroid Build Coastguard Worker  return test_tols, max(common_sizes)
129*b7c941bbSAndroid Build Coastguard Worker
130*b7c941bbSAndroid Build Coastguard Worker
131*b7c941bbSAndroid Build Coastguard Workerdef find_center_circle(
132*b7c941bbSAndroid Build Coastguard Worker    img, img_name, size, zoom_ratio, min_zoom_ratio,
133*b7c941bbSAndroid Build Coastguard Worker    expected_color=_CIRCLE_COLOR, circle_ar_rtol=_CIRCLE_AR_RTOL,
134*b7c941bbSAndroid Build Coastguard Worker    circlish_rtol=_CIRCLISH_RTOL, min_circle_pts=_MIN_CIRCLE_PTS,
135*b7c941bbSAndroid Build Coastguard Worker    fov_ratio=DEFAULT_FOV_RATIO, debug=False, draw_color=_CV2_RED,
136*b7c941bbSAndroid Build Coastguard Worker    write_img=True):
137*b7c941bbSAndroid Build Coastguard Worker  """Find circle closest to image center for scene with multiple circles.
138*b7c941bbSAndroid Build Coastguard Worker
139*b7c941bbSAndroid Build Coastguard Worker  Finds all contours in the image. Rejects those too small and not enough
140*b7c941bbSAndroid Build Coastguard Worker  points to qualify as a circle. The remaining contours must have center
141*b7c941bbSAndroid Build Coastguard Worker  point of color=color and are sorted based on distance from the center
142*b7c941bbSAndroid Build Coastguard Worker  of the image. The contour closest to the center of the image is returned.
143*b7c941bbSAndroid Build Coastguard Worker  If circle is not found due to zoom ratio being larger than ZOOM_MAX_THRESH
144*b7c941bbSAndroid Build Coastguard Worker  or the circle being cropped, None is returned.
145*b7c941bbSAndroid Build Coastguard Worker
146*b7c941bbSAndroid Build Coastguard Worker  Note: hierarchy is not used as the hierarchy for black circles changes
147*b7c941bbSAndroid Build Coastguard Worker  as the zoom level changes.
148*b7c941bbSAndroid Build Coastguard Worker
149*b7c941bbSAndroid Build Coastguard Worker  Args:
150*b7c941bbSAndroid Build Coastguard Worker    img: numpy img array with pixel values in [0,255]
151*b7c941bbSAndroid Build Coastguard Worker    img_name: str file name for saved image
152*b7c941bbSAndroid Build Coastguard Worker    size: [width, height] of the image
153*b7c941bbSAndroid Build Coastguard Worker    zoom_ratio: zoom_ratio for the particular capture
154*b7c941bbSAndroid Build Coastguard Worker    min_zoom_ratio: min_zoom_ratio supported by the camera device
155*b7c941bbSAndroid Build Coastguard Worker    expected_color: int 0 --> black, 255 --> white
156*b7c941bbSAndroid Build Coastguard Worker    circle_ar_rtol: float aspect ratio relative tolerance
157*b7c941bbSAndroid Build Coastguard Worker    circlish_rtol: float contour area vs ideal circle area pi*((w+h)/4)**2
158*b7c941bbSAndroid Build Coastguard Worker    min_circle_pts: int minimum number of points to define a circle
159*b7c941bbSAndroid Build Coastguard Worker    fov_ratio: ratio of sub camera over logical camera's field of view
160*b7c941bbSAndroid Build Coastguard Worker    debug: bool to save extra data
161*b7c941bbSAndroid Build Coastguard Worker    draw_color: cv2 color in RGB to draw circle and circle center on the image
162*b7c941bbSAndroid Build Coastguard Worker    write_img: bool: True - save image with circle and center
163*b7c941bbSAndroid Build Coastguard Worker                     False - don't save image.
164*b7c941bbSAndroid Build Coastguard Worker
165*b7c941bbSAndroid Build Coastguard Worker  Returns:
166*b7c941bbSAndroid Build Coastguard Worker    circle: [center_x, center_y, radius]
167*b7c941bbSAndroid Build Coastguard Worker  """
168*b7c941bbSAndroid Build Coastguard Worker
169*b7c941bbSAndroid Build Coastguard Worker  width, height = size
170*b7c941bbSAndroid Build Coastguard Worker  min_area = (
171*b7c941bbSAndroid Build Coastguard Worker      _MIN_AREA_RATIO * width * height * zoom_ratio * zoom_ratio * fov_ratio)
172*b7c941bbSAndroid Build Coastguard Worker
173*b7c941bbSAndroid Build Coastguard Worker  # create a copy of image to avoid modification on the original image since
174*b7c941bbSAndroid Build Coastguard Worker  # image_processing_utils.convert_image_to_uint8 uses mutable np array methods
175*b7c941bbSAndroid Build Coastguard Worker  if debug:
176*b7c941bbSAndroid Build Coastguard Worker    img = numpy.ndarray.copy(img)
177*b7c941bbSAndroid Build Coastguard Worker
178*b7c941bbSAndroid Build Coastguard Worker  # convert [0, 1] image to [0, 255] and cast as uint8
179*b7c941bbSAndroid Build Coastguard Worker  if img.dtype != numpy.uint8:
180*b7c941bbSAndroid Build Coastguard Worker    img = image_processing_utils.convert_image_to_uint8(img)
181*b7c941bbSAndroid Build Coastguard Worker
182*b7c941bbSAndroid Build Coastguard Worker  # gray scale & otsu threshold to binarize the image
183*b7c941bbSAndroid Build Coastguard Worker  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
184*b7c941bbSAndroid Build Coastguard Worker  _, img_bw = cv2.threshold(
185*b7c941bbSAndroid Build Coastguard Worker      numpy.uint8(gray), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
186*b7c941bbSAndroid Build Coastguard Worker
187*b7c941bbSAndroid Build Coastguard Worker  # use OpenCV to find contours (connected components)
188*b7c941bbSAndroid Build Coastguard Worker  contours = opencv_processing_utils.find_all_contours(255-img_bw)
189*b7c941bbSAndroid Build Coastguard Worker
190*b7c941bbSAndroid Build Coastguard Worker  # write copy of image for debug purposes
191*b7c941bbSAndroid Build Coastguard Worker  if debug:
192*b7c941bbSAndroid Build Coastguard Worker    img_copy_name = img_name.split('.')[0] + '_copy.jpg'
193*b7c941bbSAndroid Build Coastguard Worker    Image.fromarray((img_bw).astype(numpy.uint8)).save(img_copy_name)
194*b7c941bbSAndroid Build Coastguard Worker
195*b7c941bbSAndroid Build Coastguard Worker  # check contours and find the best circle candidates
196*b7c941bbSAndroid Build Coastguard Worker  circles = []
197*b7c941bbSAndroid Build Coastguard Worker  img_ctr = [gray.shape[1] // 2, gray.shape[0] // 2]
198*b7c941bbSAndroid Build Coastguard Worker  logging.debug('img center x,y: %d, %d', img_ctr[0], img_ctr[1])
199*b7c941bbSAndroid Build Coastguard Worker  logging.debug('min area: %d, min circle pts: %d', min_area, min_circle_pts)
200*b7c941bbSAndroid Build Coastguard Worker  logging.debug('circlish_rtol: %.3f', circlish_rtol)
201*b7c941bbSAndroid Build Coastguard Worker
202*b7c941bbSAndroid Build Coastguard Worker  for contour in contours:
203*b7c941bbSAndroid Build Coastguard Worker    area = cv2.contourArea(contour)
204*b7c941bbSAndroid Build Coastguard Worker    if area > min_area * _CONTOUR_AREA_LOGGING_THRESH:  # skip tiny contours
205*b7c941bbSAndroid Build Coastguard Worker      logging.debug('area: %d, min_area: %d, num_pts: %d, min_circle_pts: %d',
206*b7c941bbSAndroid Build Coastguard Worker                    area, min_area, len(contour), min_circle_pts)
207*b7c941bbSAndroid Build Coastguard Worker    if area > min_area and len(contour) >= min_circle_pts:
208*b7c941bbSAndroid Build Coastguard Worker      shape = opencv_processing_utils.component_shape(contour)
209*b7c941bbSAndroid Build Coastguard Worker      radius = (shape['width'] + shape['height']) / 4
210*b7c941bbSAndroid Build Coastguard Worker      circle_color = img_bw[shape['cty']][shape['ctx']]
211*b7c941bbSAndroid Build Coastguard Worker      circlish = round((math.pi * radius**2) / area, 4)
212*b7c941bbSAndroid Build Coastguard Worker      logging.debug('color: %s, circlish: %.2f, WxH: %dx%d',
213*b7c941bbSAndroid Build Coastguard Worker                    circle_color, circlish, shape['width'], shape['height'])
214*b7c941bbSAndroid Build Coastguard Worker      if (circle_color == expected_color and
215*b7c941bbSAndroid Build Coastguard Worker          math.isclose(1, circlish, rel_tol=circlish_rtol) and
216*b7c941bbSAndroid Build Coastguard Worker          math.isclose(shape['width'], shape['height'],
217*b7c941bbSAndroid Build Coastguard Worker                       rel_tol=circle_ar_rtol)):
218*b7c941bbSAndroid Build Coastguard Worker        logging.debug('circle found: r: %.2f, area: %.2f\n', radius, area)
219*b7c941bbSAndroid Build Coastguard Worker        circles.append([shape['ctx'], shape['cty'], radius, circlish, area])
220*b7c941bbSAndroid Build Coastguard Worker      else:
221*b7c941bbSAndroid Build Coastguard Worker        logging.debug('circle rejected: bad color, circlish or aspect ratio\n')
222*b7c941bbSAndroid Build Coastguard Worker
223*b7c941bbSAndroid Build Coastguard Worker  if not circles:
224*b7c941bbSAndroid Build Coastguard Worker    zoom_ratio_value = zoom_ratio / min_zoom_ratio
225*b7c941bbSAndroid Build Coastguard Worker    if zoom_ratio_value >= ZOOM_MAX_THRESH:
226*b7c941bbSAndroid Build Coastguard Worker      logging.debug('No circle was detected, but zoom %.2f exceeds'
227*b7c941bbSAndroid Build Coastguard Worker                    ' maximum zoom threshold', zoom_ratio_value)
228*b7c941bbSAndroid Build Coastguard Worker      return None
229*b7c941bbSAndroid Build Coastguard Worker    else:
230*b7c941bbSAndroid Build Coastguard Worker      raise AssertionError(
231*b7c941bbSAndroid Build Coastguard Worker          'No circle detected for zoom ratio <= '
232*b7c941bbSAndroid Build Coastguard Worker          f'{ZOOM_MAX_THRESH}. '
233*b7c941bbSAndroid Build Coastguard Worker          'Take pictures according to instructions carefully!')
234*b7c941bbSAndroid Build Coastguard Worker  else:
235*b7c941bbSAndroid Build Coastguard Worker    logging.debug('num of circles found: %s', len(circles))
236*b7c941bbSAndroid Build Coastguard Worker
237*b7c941bbSAndroid Build Coastguard Worker  if debug:
238*b7c941bbSAndroid Build Coastguard Worker    logging.debug('circles [x, y, r, pi*r**2/area, area]: %s', str(circles))
239*b7c941bbSAndroid Build Coastguard Worker
240*b7c941bbSAndroid Build Coastguard Worker  # find circle closest to center
241*b7c941bbSAndroid Build Coastguard Worker  circle = min(
242*b7c941bbSAndroid Build Coastguard Worker      circles, key=lambda x: math.hypot(x[0] - img_ctr[0], x[1] - img_ctr[1]))
243*b7c941bbSAndroid Build Coastguard Worker
244*b7c941bbSAndroid Build Coastguard Worker  # check if circle is cropped because of zoom factor
245*b7c941bbSAndroid Build Coastguard Worker  if opencv_processing_utils.is_circle_cropped(circle, size):
246*b7c941bbSAndroid Build Coastguard Worker    logging.debug('zoom %.2f is too large! Skip further captures', zoom_ratio)
247*b7c941bbSAndroid Build Coastguard Worker    return None
248*b7c941bbSAndroid Build Coastguard Worker
249*b7c941bbSAndroid Build Coastguard Worker  # mark image center
250*b7c941bbSAndroid Build Coastguard Worker  size = gray.shape
251*b7c941bbSAndroid Build Coastguard Worker  m_x, m_y = size[1] // 2, size[0] // 2
252*b7c941bbSAndroid Build Coastguard Worker  marker_size = _CV2_LINE_THICKNESS * 10
253*b7c941bbSAndroid Build Coastguard Worker  cv2.drawMarker(img, (m_x, m_y), draw_color, markerType=cv2.MARKER_CROSS,
254*b7c941bbSAndroid Build Coastguard Worker                 markerSize=marker_size, thickness=_CV2_LINE_THICKNESS)
255*b7c941bbSAndroid Build Coastguard Worker
256*b7c941bbSAndroid Build Coastguard Worker  # add circle to saved image
257*b7c941bbSAndroid Build Coastguard Worker  center_i = (int(round(circle[0], 0)), int(round(circle[1], 0)))
258*b7c941bbSAndroid Build Coastguard Worker  radius_i = int(round(circle[2], 0))
259*b7c941bbSAndroid Build Coastguard Worker  cv2.circle(img, center_i, radius_i, draw_color, _CV2_LINE_THICKNESS)
260*b7c941bbSAndroid Build Coastguard Worker  if write_img:
261*b7c941bbSAndroid Build Coastguard Worker    image_processing_utils.write_image(img / 255.0, img_name)
262*b7c941bbSAndroid Build Coastguard Worker
263*b7c941bbSAndroid Build Coastguard Worker  return circle
264*b7c941bbSAndroid Build Coastguard Worker
265*b7c941bbSAndroid Build Coastguard Worker
266*b7c941bbSAndroid Build Coastguard Workerdef preview_zoom_data_to_string(test_data):
267*b7c941bbSAndroid Build Coastguard Worker  """Returns formatted string from test_data.
268*b7c941bbSAndroid Build Coastguard Worker
269*b7c941bbSAndroid Build Coastguard Worker  Floats are capped at 2 floating points.
270*b7c941bbSAndroid Build Coastguard Worker
271*b7c941bbSAndroid Build Coastguard Worker  Args:
272*b7c941bbSAndroid Build Coastguard Worker    test_data: ZoomTestData with relevant test data.
273*b7c941bbSAndroid Build Coastguard Worker
274*b7c941bbSAndroid Build Coastguard Worker  Returns:
275*b7c941bbSAndroid Build Coastguard Worker    Formatted String
276*b7c941bbSAndroid Build Coastguard Worker  """
277*b7c941bbSAndroid Build Coastguard Worker  output = []
278*b7c941bbSAndroid Build Coastguard Worker  for key, value in dataclasses.asdict(test_data).items():
279*b7c941bbSAndroid Build Coastguard Worker    if isinstance(value, float):
280*b7c941bbSAndroid Build Coastguard Worker      output.append(f'{key}: {value:.2f}')
281*b7c941bbSAndroid Build Coastguard Worker    elif isinstance(value, list):
282*b7c941bbSAndroid Build Coastguard Worker      output.append(
283*b7c941bbSAndroid Build Coastguard Worker          f"{key}: [{', '.join([f'{item:.2f}' for item in value])}]")
284*b7c941bbSAndroid Build Coastguard Worker    else:
285*b7c941bbSAndroid Build Coastguard Worker      output.append(f'{key}: {value}')
286*b7c941bbSAndroid Build Coastguard Worker
287*b7c941bbSAndroid Build Coastguard Worker  return ', '.join(output)
288*b7c941bbSAndroid Build Coastguard Worker
289*b7c941bbSAndroid Build Coastguard Worker
290*b7c941bbSAndroid Build Coastguard Workerdef _get_aruco_marker_x_y_offset(aruco_corners, size):
291*b7c941bbSAndroid Build Coastguard Worker  """Get the x and y distances from the ArUco marker to the image center.
292*b7c941bbSAndroid Build Coastguard Worker
293*b7c941bbSAndroid Build Coastguard Worker  Args:
294*b7c941bbSAndroid Build Coastguard Worker    aruco_corners: list of 4 Iterables, each tuple is a (x, y) coordinate of a
295*b7c941bbSAndroid Build Coastguard Worker      corner.
296*b7c941bbSAndroid Build Coastguard Worker    size: Iterable; the width and height of the images.
297*b7c941bbSAndroid Build Coastguard Worker  Returns:
298*b7c941bbSAndroid Build Coastguard Worker    The x and y distances from the ArUco marker to the center of the image.
299*b7c941bbSAndroid Build Coastguard Worker  """
300*b7c941bbSAndroid Build Coastguard Worker  aruco_marker_x, aruco_marker_y = opencv_processing_utils.get_aruco_center(
301*b7c941bbSAndroid Build Coastguard Worker      aruco_corners)
302*b7c941bbSAndroid Build Coastguard Worker  return aruco_marker_x - size[0] // 2, aruco_marker_y - size[1] // 2
303*b7c941bbSAndroid Build Coastguard Worker
304*b7c941bbSAndroid Build Coastguard Worker
305*b7c941bbSAndroid Build Coastguard Workerdef _get_aruco_marker_offset(aruco_corners, size):
306*b7c941bbSAndroid Build Coastguard Worker  """Get the distance from the chosen ArUco marker to the center of the image.
307*b7c941bbSAndroid Build Coastguard Worker
308*b7c941bbSAndroid Build Coastguard Worker  Args:
309*b7c941bbSAndroid Build Coastguard Worker    aruco_corners: list of 4 Iterables, each tuple is a (x, y) coordinate of a
310*b7c941bbSAndroid Build Coastguard Worker      corner.
311*b7c941bbSAndroid Build Coastguard Worker    size: Iterable; the width and height of the images.
312*b7c941bbSAndroid Build Coastguard Worker  Returns:
313*b7c941bbSAndroid Build Coastguard Worker    The distance from the ArUco marker to the center of the image.
314*b7c941bbSAndroid Build Coastguard Worker  """
315*b7c941bbSAndroid Build Coastguard Worker  return math.hypot(*_get_aruco_marker_x_y_offset(aruco_corners, size))
316*b7c941bbSAndroid Build Coastguard Worker
317*b7c941bbSAndroid Build Coastguard Worker
318*b7c941bbSAndroid Build Coastguard Workerdef _get_shortest_focal_length(props):
319*b7c941bbSAndroid Build Coastguard Worker  """Return the first available focal length from properties."""
320*b7c941bbSAndroid Build Coastguard Worker  return props['android.lens.info.availableFocalLengths'][0]
321*b7c941bbSAndroid Build Coastguard Worker
322*b7c941bbSAndroid Build Coastguard Worker
323*b7c941bbSAndroid Build Coastguard Workerdef _get_average_offset(shared_id, aruco_ids, aruco_corners, size):
324*b7c941bbSAndroid Build Coastguard Worker  """Get the average offset a given marker to the image center.
325*b7c941bbSAndroid Build Coastguard Worker
326*b7c941bbSAndroid Build Coastguard Worker  Args:
327*b7c941bbSAndroid Build Coastguard Worker    shared_id: ID of the given marker to find the average offset.
328*b7c941bbSAndroid Build Coastguard Worker    aruco_ids: nested Iterables of ArUco marker IDs.
329*b7c941bbSAndroid Build Coastguard Worker    aruco_corners: nested Iterables of ArUco marker corners.
330*b7c941bbSAndroid Build Coastguard Worker    size: size of the image to calculate image center.
331*b7c941bbSAndroid Build Coastguard Worker  Returns:
332*b7c941bbSAndroid Build Coastguard Worker    The average offset from the given marker to the image center.
333*b7c941bbSAndroid Build Coastguard Worker  """
334*b7c941bbSAndroid Build Coastguard Worker  offsets = []
335*b7c941bbSAndroid Build Coastguard Worker  for ids, corners in zip(aruco_ids, aruco_corners):
336*b7c941bbSAndroid Build Coastguard Worker    offsets.append(
337*b7c941bbSAndroid Build Coastguard Worker        _get_average_offset_from_single_capture(
338*b7c941bbSAndroid Build Coastguard Worker            shared_id, ids, corners, size))
339*b7c941bbSAndroid Build Coastguard Worker  return numpy.mean(offsets)
340*b7c941bbSAndroid Build Coastguard Worker
341*b7c941bbSAndroid Build Coastguard Worker
342*b7c941bbSAndroid Build Coastguard Workerdef _get_average_offset_from_single_capture(
343*b7c941bbSAndroid Build Coastguard Worker    shared_id, ids, corners, size):
344*b7c941bbSAndroid Build Coastguard Worker  """Get the average offset a given marker to a known image's center.
345*b7c941bbSAndroid Build Coastguard Worker
346*b7c941bbSAndroid Build Coastguard Worker  Args:
347*b7c941bbSAndroid Build Coastguard Worker    shared_id: ID of the given marker to find the average offset.
348*b7c941bbSAndroid Build Coastguard Worker    ids: Iterable of ArUco marker IDs for single capture test data.
349*b7c941bbSAndroid Build Coastguard Worker    corners: Iterable of ArUco marker corners for single capture test data.
350*b7c941bbSAndroid Build Coastguard Worker    size: size of the image to calculate image center.
351*b7c941bbSAndroid Build Coastguard Worker  Returns:
352*b7c941bbSAndroid Build Coastguard Worker    The average offset from the given marker to the image center.
353*b7c941bbSAndroid Build Coastguard Worker  """
354*b7c941bbSAndroid Build Coastguard Worker  corresponding_corners = corners[numpy.where(ids == shared_id)[0][0]]
355*b7c941bbSAndroid Build Coastguard Worker  return _get_aruco_marker_offset(corresponding_corners, size)
356*b7c941bbSAndroid Build Coastguard Worker
357*b7c941bbSAndroid Build Coastguard Worker
358*b7c941bbSAndroid Build Coastguard Workerdef _are_values_non_decreasing(values, abs_tol=0):
359*b7c941bbSAndroid Build Coastguard Worker  """Returns True if any values are not decreasing with absolute tolerance."""
360*b7c941bbSAndroid Build Coastguard Worker  return all(x < y + abs_tol for x, y in zip(values, values[1:]))
361*b7c941bbSAndroid Build Coastguard Worker
362*b7c941bbSAndroid Build Coastguard Worker
363*b7c941bbSAndroid Build Coastguard Workerdef _are_values_non_increasing(values, abs_tol=0):
364*b7c941bbSAndroid Build Coastguard Worker  """Returns True if any values are not increasing with absolute tolerance."""
365*b7c941bbSAndroid Build Coastguard Worker  return all(x > y - abs_tol for x, y in zip(values, values[1:]))
366*b7c941bbSAndroid Build Coastguard Worker
367*b7c941bbSAndroid Build Coastguard Worker
368*b7c941bbSAndroid Build Coastguard Workerdef _verify_offset_monotonicity(offsets):
369*b7c941bbSAndroid Build Coastguard Worker  """Returns if values continuously increase or decrease with tolerance."""
370*b7c941bbSAndroid Build Coastguard Worker  return (
371*b7c941bbSAndroid Build Coastguard Worker      _are_values_non_decreasing(
372*b7c941bbSAndroid Build Coastguard Worker          offsets, _SMOOTH_ZOOM_OFFSET_MONOTONICITY_ATOL) or
373*b7c941bbSAndroid Build Coastguard Worker      _are_values_non_increasing(
374*b7c941bbSAndroid Build Coastguard Worker          offsets, _SMOOTH_ZOOM_OFFSET_MONOTONICITY_ATOL)
375*b7c941bbSAndroid Build Coastguard Worker  )
376*b7c941bbSAndroid Build Coastguard Worker
377*b7c941bbSAndroid Build Coastguard Worker
378*b7c941bbSAndroid Build Coastguard Workerdef update_zoom_test_data_with_shared_aruco_marker(
379*b7c941bbSAndroid Build Coastguard Worker    test_data, aruco_ids, aruco_corners, size):
380*b7c941bbSAndroid Build Coastguard Worker  """Update test_data in place with a shared ArUco marker if available.
381*b7c941bbSAndroid Build Coastguard Worker
382*b7c941bbSAndroid Build Coastguard Worker  Iterates through the list of aruco_ids and aruco_corners to find the shared
383*b7c941bbSAndroid Build Coastguard Worker  ArUco marker that is closest to the center across all captures. If found,
384*b7c941bbSAndroid Build Coastguard Worker  updates the test_data with the shared marker and its offset from the
385*b7c941bbSAndroid Build Coastguard Worker  image center.
386*b7c941bbSAndroid Build Coastguard Worker
387*b7c941bbSAndroid Build Coastguard Worker  Args:
388*b7c941bbSAndroid Build Coastguard Worker    test_data: list of ZoomTestData.
389*b7c941bbSAndroid Build Coastguard Worker    aruco_ids: nested Iterables of ArUco marker IDs.
390*b7c941bbSAndroid Build Coastguard Worker    aruco_corners: nested Iterables of ArUco marker corners.
391*b7c941bbSAndroid Build Coastguard Worker    size: Iterable; the width and height of the images.
392*b7c941bbSAndroid Build Coastguard Worker  """
393*b7c941bbSAndroid Build Coastguard Worker  shared_ids = set(list(aruco_ids[0]))
394*b7c941bbSAndroid Build Coastguard Worker  for ids in aruco_ids[1:]:
395*b7c941bbSAndroid Build Coastguard Worker    shared_ids.intersection_update(list(ids))
396*b7c941bbSAndroid Build Coastguard Worker  # Choose closest shared marker to center of transition image if possible.
397*b7c941bbSAndroid Build Coastguard Worker  if shared_ids:
398*b7c941bbSAndroid Build Coastguard Worker    for i, (ids, corners) in enumerate(zip(aruco_ids, aruco_corners)):
399*b7c941bbSAndroid Build Coastguard Worker      if test_data[i].physical_id != test_data[0].physical_id:
400*b7c941bbSAndroid Build Coastguard Worker        transition_aruco_ids = ids
401*b7c941bbSAndroid Build Coastguard Worker        transition_aruco_corners = corners
402*b7c941bbSAndroid Build Coastguard Worker        shared_id = min(
403*b7c941bbSAndroid Build Coastguard Worker            shared_ids,
404*b7c941bbSAndroid Build Coastguard Worker            key=lambda i: _get_average_offset_from_single_capture(
405*b7c941bbSAndroid Build Coastguard Worker                i, transition_aruco_ids, transition_aruco_corners, size)
406*b7c941bbSAndroid Build Coastguard Worker        )
407*b7c941bbSAndroid Build Coastguard Worker        break
408*b7c941bbSAndroid Build Coastguard Worker    else:
409*b7c941bbSAndroid Build Coastguard Worker      shared_id = min(
410*b7c941bbSAndroid Build Coastguard Worker        shared_ids,
411*b7c941bbSAndroid Build Coastguard Worker        key=lambda i: _get_average_offset(i, aruco_ids, aruco_corners, size)
412*b7c941bbSAndroid Build Coastguard Worker    )
413*b7c941bbSAndroid Build Coastguard Worker  else:
414*b7c941bbSAndroid Build Coastguard Worker    raise AssertionError('No shared ArUco marker found across all captures.')
415*b7c941bbSAndroid Build Coastguard Worker  logging.debug('Using shared aruco ID %d', shared_id)
416*b7c941bbSAndroid Build Coastguard Worker  for i, (ids, corners) in enumerate(zip(aruco_ids, aruco_corners)):
417*b7c941bbSAndroid Build Coastguard Worker    index = numpy.where(ids == shared_id)[0][0]
418*b7c941bbSAndroid Build Coastguard Worker    corresponding_corners = corners[index]
419*b7c941bbSAndroid Build Coastguard Worker    logging.debug('Corners of shared ID: %s', corresponding_corners)
420*b7c941bbSAndroid Build Coastguard Worker    test_data[i].aruco_corners = corresponding_corners
421*b7c941bbSAndroid Build Coastguard Worker    test_data[i].aruco_offset = (
422*b7c941bbSAndroid Build Coastguard Worker        _get_aruco_marker_offset(
423*b7c941bbSAndroid Build Coastguard Worker            corresponding_corners, size
424*b7c941bbSAndroid Build Coastguard Worker        )
425*b7c941bbSAndroid Build Coastguard Worker    )
426*b7c941bbSAndroid Build Coastguard Worker
427*b7c941bbSAndroid Build Coastguard Worker
428*b7c941bbSAndroid Build Coastguard Workerdef verify_zoom_results(test_data, size, z_max, z_min,
429*b7c941bbSAndroid Build Coastguard Worker                        offset_plot_name_stem=None):
430*b7c941bbSAndroid Build Coastguard Worker  """Verify that the output images' zoom level reflects the correct zoom ratios.
431*b7c941bbSAndroid Build Coastguard Worker
432*b7c941bbSAndroid Build Coastguard Worker  This test verifies that the center and radius of the circles in the output
433*b7c941bbSAndroid Build Coastguard Worker  images reflects the zoom ratios being set. The larger the zoom ratio, the
434*b7c941bbSAndroid Build Coastguard Worker  larger the circle. And the distance from the center of the circle to the
435*b7c941bbSAndroid Build Coastguard Worker  center of the image is proportional to the zoom ratio as well.
436*b7c941bbSAndroid Build Coastguard Worker
437*b7c941bbSAndroid Build Coastguard Worker  Args:
438*b7c941bbSAndroid Build Coastguard Worker    test_data: Iterable[ZoomTestData]
439*b7c941bbSAndroid Build Coastguard Worker    size: array; the width and height of the images
440*b7c941bbSAndroid Build Coastguard Worker    z_max: float; the maximum zoom ratio being tested
441*b7c941bbSAndroid Build Coastguard Worker    z_min: float; the minimum zoom ratio being tested
442*b7c941bbSAndroid Build Coastguard Worker    offset_plot_name_stem: Optional[str]; log path and name of the offset plot
443*b7c941bbSAndroid Build Coastguard Worker
444*b7c941bbSAndroid Build Coastguard Worker  Returns:
445*b7c941bbSAndroid Build Coastguard Worker    Boolean whether the test passes (True) or not (False)
446*b7c941bbSAndroid Build Coastguard Worker  """
447*b7c941bbSAndroid Build Coastguard Worker  # assert some range is tested before circles get too big
448*b7c941bbSAndroid Build Coastguard Worker  test_success = True
449*b7c941bbSAndroid Build Coastguard Worker
450*b7c941bbSAndroid Build Coastguard Worker  zoom_max_thresh = ZOOM_MAX_THRESH
451*b7c941bbSAndroid Build Coastguard Worker  z_max_ratio = z_max / z_min
452*b7c941bbSAndroid Build Coastguard Worker  if z_max_ratio < ZOOM_MAX_THRESH:
453*b7c941bbSAndroid Build Coastguard Worker    zoom_max_thresh = z_max_ratio
454*b7c941bbSAndroid Build Coastguard Worker
455*b7c941bbSAndroid Build Coastguard Worker  # handle capture orders like [1, 0.5, 1.5, 2...]
456*b7c941bbSAndroid Build Coastguard Worker  test_data_zoom_values = [v.result_zoom for v in test_data]
457*b7c941bbSAndroid Build Coastguard Worker  test_data_max_z = max(test_data_zoom_values) / min(test_data_zoom_values)
458*b7c941bbSAndroid Build Coastguard Worker  logging.debug('test zoom ratio max: %.2f vs threshold %.2f',
459*b7c941bbSAndroid Build Coastguard Worker                test_data_max_z, zoom_max_thresh)
460*b7c941bbSAndroid Build Coastguard Worker  if not math.isclose(
461*b7c941bbSAndroid Build Coastguard Worker      test_data_max_z, zoom_max_thresh, rel_tol=ZOOM_RTOL):
462*b7c941bbSAndroid Build Coastguard Worker    test_success = False
463*b7c941bbSAndroid Build Coastguard Worker    e_msg = (f'Max zoom ratio tested: {test_data_max_z:.4f}, '
464*b7c941bbSAndroid Build Coastguard Worker             f'range advertised min: {z_min}, max: {z_max} '
465*b7c941bbSAndroid Build Coastguard Worker             f'THRESH: {zoom_max_thresh + ZOOM_RTOL}')
466*b7c941bbSAndroid Build Coastguard Worker    logging.error(e_msg)
467*b7c941bbSAndroid Build Coastguard Worker  return test_success and verify_zoom_data(
468*b7c941bbSAndroid Build Coastguard Worker      test_data, size, offset_plot_name_stem=offset_plot_name_stem)
469*b7c941bbSAndroid Build Coastguard Worker
470*b7c941bbSAndroid Build Coastguard Worker
471*b7c941bbSAndroid Build Coastguard Workerdef verify_zoom_data(
472*b7c941bbSAndroid Build Coastguard Worker    test_data, size,
473*b7c941bbSAndroid Build Coastguard Worker    plot_name_stem=None, offset_plot_name_stem=None,
474*b7c941bbSAndroid Build Coastguard Worker    number_of_cameras_to_test=0):
475*b7c941bbSAndroid Build Coastguard Worker  """Verify that the output images' zoom level reflects the correct zoom ratios.
476*b7c941bbSAndroid Build Coastguard Worker
477*b7c941bbSAndroid Build Coastguard Worker  This test verifies that the center and side length of the ArUco markers in
478*b7c941bbSAndroid Build Coastguard Worker  the output images reflects the zoom ratios being set. ArUco marker side length
479*b7c941bbSAndroid Build Coastguard Worker  should increase proportionally to the zoom ratio. The distance from the
480*b7c941bbSAndroid Build Coastguard Worker  center of the ArUco marker to the center of the image (offset) should either
481*b7c941bbSAndroid Build Coastguard Worker  change proportionally to the zoom ratio, or decrease/increase toward the
482*b7c941bbSAndroid Build Coastguard Worker  offset of the first capture using the upcoming physical camera, if there is
483*b7c941bbSAndroid Build Coastguard Worker  a camera switch.
484*b7c941bbSAndroid Build Coastguard Worker
485*b7c941bbSAndroid Build Coastguard Worker  Args:
486*b7c941bbSAndroid Build Coastguard Worker    test_data: Iterable[ZoomTestData]
487*b7c941bbSAndroid Build Coastguard Worker    size: array; the width and height of the images
488*b7c941bbSAndroid Build Coastguard Worker    plot_name_stem: Optional[str]; log path and name of the plot
489*b7c941bbSAndroid Build Coastguard Worker    offset_plot_name_stem: Optional[str]; log path and name of the offset plot
490*b7c941bbSAndroid Build Coastguard Worker    number_of_cameras_to_test: [Optional][int]; minimum cameras in ZoomTestData
491*b7c941bbSAndroid Build Coastguard Worker
492*b7c941bbSAndroid Build Coastguard Worker  Returns:
493*b7c941bbSAndroid Build Coastguard Worker    Boolean whether the test passes (True) or not (False)
494*b7c941bbSAndroid Build Coastguard Worker  """
495*b7c941bbSAndroid Build Coastguard Worker  range_success = True
496*b7c941bbSAndroid Build Coastguard Worker
497*b7c941bbSAndroid Build Coastguard Worker  # assert that multiple cameras were tested where applicable
498*b7c941bbSAndroid Build Coastguard Worker  ids_tested = set([v.physical_id for v in test_data])
499*b7c941bbSAndroid Build Coastguard Worker  if len(ids_tested) < number_of_cameras_to_test:
500*b7c941bbSAndroid Build Coastguard Worker    range_success = False
501*b7c941bbSAndroid Build Coastguard Worker    logging.error('Expected at least %d physical cameras tested, '
502*b7c941bbSAndroid Build Coastguard Worker                  'found IDs: %s', number_of_cameras_to_test, ids_tested)
503*b7c941bbSAndroid Build Coastguard Worker
504*b7c941bbSAndroid Build Coastguard Worker  side_success = True
505*b7c941bbSAndroid Build Coastguard Worker  offset_success = True
506*b7c941bbSAndroid Build Coastguard Worker  used_smooth_offset = False
507*b7c941bbSAndroid Build Coastguard Worker
508*b7c941bbSAndroid Build Coastguard Worker  # initialize relative size w/ zoom[0] for diff zoom ratio checks
509*b7c941bbSAndroid Build Coastguard Worker  side_0 = opencv_processing_utils.get_aruco_marker_side_length(
510*b7c941bbSAndroid Build Coastguard Worker      test_data[0].aruco_corners)
511*b7c941bbSAndroid Build Coastguard Worker  z_0 = float(test_data[0].result_zoom)
512*b7c941bbSAndroid Build Coastguard Worker
513*b7c941bbSAndroid Build Coastguard Worker  # use 1x ~ 1.1x data as base image if available
514*b7c941bbSAndroid Build Coastguard Worker  if z_0 < PREFERRED_BASE_ZOOM_RATIO:
515*b7c941bbSAndroid Build Coastguard Worker    for data in test_data:
516*b7c941bbSAndroid Build Coastguard Worker      if (data.result_zoom >= PREFERRED_BASE_ZOOM_RATIO and
517*b7c941bbSAndroid Build Coastguard Worker          math.isclose(data.result_zoom, PREFERRED_BASE_ZOOM_RATIO,
518*b7c941bbSAndroid Build Coastguard Worker                       rel_tol=PREFERRED_BASE_ZOOM_RATIO_RTOL)):
519*b7c941bbSAndroid Build Coastguard Worker        side_0 = opencv_processing_utils.get_aruco_marker_side_length(
520*b7c941bbSAndroid Build Coastguard Worker            data.aruco_corners)
521*b7c941bbSAndroid Build Coastguard Worker        z_0 = float(data.result_zoom)
522*b7c941bbSAndroid Build Coastguard Worker        break
523*b7c941bbSAndroid Build Coastguard Worker  logging.debug('z_0: %.3f, side_0: %.3f', z_0, side_0)
524*b7c941bbSAndroid Build Coastguard Worker  if plot_name_stem:
525*b7c941bbSAndroid Build Coastguard Worker    frame_numbers = []
526*b7c941bbSAndroid Build Coastguard Worker    z_variations = []
527*b7c941bbSAndroid Build Coastguard Worker    rel_variations = []
528*b7c941bbSAndroid Build Coastguard Worker    radius_tols = []
529*b7c941bbSAndroid Build Coastguard Worker    max_rel_variation = None
530*b7c941bbSAndroid Build Coastguard Worker    max_rel_variation_zoom = None
531*b7c941bbSAndroid Build Coastguard Worker  offset_x_values = []
532*b7c941bbSAndroid Build Coastguard Worker  offset_y_values = []
533*b7c941bbSAndroid Build Coastguard Worker  hypots = []
534*b7c941bbSAndroid Build Coastguard Worker
535*b7c941bbSAndroid Build Coastguard Worker  id_to_next_offset = {}
536*b7c941bbSAndroid Build Coastguard Worker  offsets_while_transitioning = []
537*b7c941bbSAndroid Build Coastguard Worker  previous_id = test_data[0].physical_id
538*b7c941bbSAndroid Build Coastguard Worker  # First pass to get transition points
539*b7c941bbSAndroid Build Coastguard Worker  for i, data in enumerate(test_data):
540*b7c941bbSAndroid Build Coastguard Worker    if i == 0:
541*b7c941bbSAndroid Build Coastguard Worker      continue
542*b7c941bbSAndroid Build Coastguard Worker    if test_data[i-1].physical_id != data.physical_id:
543*b7c941bbSAndroid Build Coastguard Worker      id_to_next_offset[previous_id] = data.aruco_offset
544*b7c941bbSAndroid Build Coastguard Worker      previous_id = data.physical_id
545*b7c941bbSAndroid Build Coastguard Worker
546*b7c941bbSAndroid Build Coastguard Worker  initial_offset = test_data[0].aruco_offset
547*b7c941bbSAndroid Build Coastguard Worker  initial_zoom = test_data[0].result_zoom
548*b7c941bbSAndroid Build Coastguard Worker  # Second pass to check offset correctness
549*b7c941bbSAndroid Build Coastguard Worker  for i, data in enumerate(test_data):
550*b7c941bbSAndroid Build Coastguard Worker    logging.debug(' ')  # add blank line between frames
551*b7c941bbSAndroid Build Coastguard Worker    logging.debug('Frame# %d {%s}', i, preview_zoom_data_to_string(data))
552*b7c941bbSAndroid Build Coastguard Worker    logging.debug('Zoom: %.2f, physical ID: %s',
553*b7c941bbSAndroid Build Coastguard Worker                  data.result_zoom, data.physical_id)
554*b7c941bbSAndroid Build Coastguard Worker    offset_x, offset_y = _get_aruco_marker_x_y_offset(data.aruco_corners, size)
555*b7c941bbSAndroid Build Coastguard Worker    offset_x_values.append(offset_x)
556*b7c941bbSAndroid Build Coastguard Worker    offset_y_values.append(offset_y)
557*b7c941bbSAndroid Build Coastguard Worker    z_ratio = data.result_zoom / z_0
558*b7c941bbSAndroid Build Coastguard Worker
559*b7c941bbSAndroid Build Coastguard Worker    # check relative size against zoom[0]
560*b7c941bbSAndroid Build Coastguard Worker    current_side = opencv_processing_utils.get_aruco_marker_side_length(
561*b7c941bbSAndroid Build Coastguard Worker        data.aruco_corners)
562*b7c941bbSAndroid Build Coastguard Worker    side_ratio = current_side / side_0
563*b7c941bbSAndroid Build Coastguard Worker
564*b7c941bbSAndroid Build Coastguard Worker    # Calculate variations
565*b7c941bbSAndroid Build Coastguard Worker    z_variation = z_ratio - side_ratio
566*b7c941bbSAndroid Build Coastguard Worker    relative_variation = abs(z_variation) / max(abs(z_ratio), abs(side_ratio))
567*b7c941bbSAndroid Build Coastguard Worker
568*b7c941bbSAndroid Build Coastguard Worker    # Store values for plotting
569*b7c941bbSAndroid Build Coastguard Worker    if plot_name_stem:
570*b7c941bbSAndroid Build Coastguard Worker      frame_numbers.append(i)
571*b7c941bbSAndroid Build Coastguard Worker      z_variations.append(z_variation)
572*b7c941bbSAndroid Build Coastguard Worker      rel_variations.append(relative_variation)
573*b7c941bbSAndroid Build Coastguard Worker      radius_tols.append(data.radius_tol)
574*b7c941bbSAndroid Build Coastguard Worker      if max_rel_variation is None or relative_variation > max_rel_variation:
575*b7c941bbSAndroid Build Coastguard Worker        max_rel_variation = relative_variation
576*b7c941bbSAndroid Build Coastguard Worker        max_rel_variation_zoom = data.result_zoom
577*b7c941bbSAndroid Build Coastguard Worker
578*b7c941bbSAndroid Build Coastguard Worker    logging.debug('r ratio req: %.3f, measured: %.3f',
579*b7c941bbSAndroid Build Coastguard Worker                  z_ratio, side_ratio)
580*b7c941bbSAndroid Build Coastguard Worker    msg = (
581*b7c941bbSAndroid Build Coastguard Worker        f'{i} Marker side ratio: result({data.result_zoom:.3f}/{z_0:.3f}):'
582*b7c941bbSAndroid Build Coastguard Worker        f' {z_ratio:.3f}, marker({current_side:.3f}/{side_0:.3f}):'
583*b7c941bbSAndroid Build Coastguard Worker        f' {side_ratio:.3f}, RTOL: {data.radius_tol}'
584*b7c941bbSAndroid Build Coastguard Worker    )
585*b7c941bbSAndroid Build Coastguard Worker    if not math.isclose(z_ratio, side_ratio, rel_tol=data.radius_tol):
586*b7c941bbSAndroid Build Coastguard Worker      side_success = False
587*b7c941bbSAndroid Build Coastguard Worker      logging.error(msg)
588*b7c941bbSAndroid Build Coastguard Worker    else:
589*b7c941bbSAndroid Build Coastguard Worker      logging.debug(msg)
590*b7c941bbSAndroid Build Coastguard Worker
591*b7c941bbSAndroid Build Coastguard Worker    # check relative offset against init vals w/ no focal length change
592*b7c941bbSAndroid Build Coastguard Worker    # set init values for first capture or change in physical cam focal length
593*b7c941bbSAndroid Build Coastguard Worker    hypots.append(data.aruco_offset)
594*b7c941bbSAndroid Build Coastguard Worker    if i == 0:
595*b7c941bbSAndroid Build Coastguard Worker      continue
596*b7c941bbSAndroid Build Coastguard Worker    if test_data[i-1].physical_id != data.physical_id:
597*b7c941bbSAndroid Build Coastguard Worker      initial_zoom = float(data.result_zoom)
598*b7c941bbSAndroid Build Coastguard Worker      initial_offset = data.aruco_offset
599*b7c941bbSAndroid Build Coastguard Worker      logging.debug('offset_hypot_init: %.3f', initial_offset)
600*b7c941bbSAndroid Build Coastguard Worker      d_msg = (f'-- init {i} zoom: {data.result_zoom:.2f}, '
601*b7c941bbSAndroid Build Coastguard Worker               f'offset init: {initial_offset:.1f}, '
602*b7c941bbSAndroid Build Coastguard Worker               f'zoom: {z_ratio:.1f} ')
603*b7c941bbSAndroid Build Coastguard Worker      logging.debug(d_msg)
604*b7c941bbSAndroid Build Coastguard Worker      if offsets_while_transitioning:
605*b7c941bbSAndroid Build Coastguard Worker        logging.debug('Offsets while transitioning: %s',
606*b7c941bbSAndroid Build Coastguard Worker                      offsets_while_transitioning)
607*b7c941bbSAndroid Build Coastguard Worker        if used_smooth_offset and not _verify_offset_monotonicity(
608*b7c941bbSAndroid Build Coastguard Worker            offsets_while_transitioning):
609*b7c941bbSAndroid Build Coastguard Worker          logging.error('Offsets %s are not monotonic',
610*b7c941bbSAndroid Build Coastguard Worker                        offsets_while_transitioning)
611*b7c941bbSAndroid Build Coastguard Worker          offset_success = False
612*b7c941bbSAndroid Build Coastguard Worker        offsets_while_transitioning.clear()
613*b7c941bbSAndroid Build Coastguard Worker    else:
614*b7c941bbSAndroid Build Coastguard Worker      offsets_while_transitioning.append(data.aruco_offset)
615*b7c941bbSAndroid Build Coastguard Worker      z_ratio = data.result_zoom / initial_zoom
616*b7c941bbSAndroid Build Coastguard Worker      offset_hypot_rel = data.aruco_offset / z_ratio
617*b7c941bbSAndroid Build Coastguard Worker      logging.debug('offset_hypot_rel: %.3f', offset_hypot_rel)
618*b7c941bbSAndroid Build Coastguard Worker      rel_tol = data.offset_tol
619*b7c941bbSAndroid Build Coastguard Worker      if not math.isclose(initial_offset, offset_hypot_rel,
620*b7c941bbSAndroid Build Coastguard Worker                          rel_tol=rel_tol, abs_tol=_OFFSET_ATOL):
621*b7c941bbSAndroid Build Coastguard Worker        w_msg = ('Original offset check failed. '
622*b7c941bbSAndroid Build Coastguard Worker                 f'{i} zoom: {data.result_zoom:.2f}, '
623*b7c941bbSAndroid Build Coastguard Worker                 f'offset init: {initial_offset:.4f}, '
624*b7c941bbSAndroid Build Coastguard Worker                 f'offset rel: {offset_hypot_rel:.4f}, '
625*b7c941bbSAndroid Build Coastguard Worker                 f'Zoom: {z_ratio:.1f}, '
626*b7c941bbSAndroid Build Coastguard Worker                 f'RTOL: {rel_tol}, ATOL: {_OFFSET_ATOL}')
627*b7c941bbSAndroid Build Coastguard Worker        logging.warning(w_msg)
628*b7c941bbSAndroid Build Coastguard Worker        used_smooth_offset = True
629*b7c941bbSAndroid Build Coastguard Worker        if data.physical_id not in id_to_next_offset:
630*b7c941bbSAndroid Build Coastguard Worker          offset_success = False
631*b7c941bbSAndroid Build Coastguard Worker          logging.error('No physical camera is available to explain '
632*b7c941bbSAndroid Build Coastguard Worker                        'offset changes!')
633*b7c941bbSAndroid Build Coastguard Worker        else:
634*b7c941bbSAndroid Build Coastguard Worker          next_initial_offset = id_to_next_offset[data.physical_id]
635*b7c941bbSAndroid Build Coastguard Worker          if not math.isclose(next_initial_offset, data.aruco_offset,
636*b7c941bbSAndroid Build Coastguard Worker                              rel_tol=OFFSET_RTOL_SMOOTH_ZOOM,
637*b7c941bbSAndroid Build Coastguard Worker                              abs_tol=_OFFSET_ATOL):
638*b7c941bbSAndroid Build Coastguard Worker            offset_success = False
639*b7c941bbSAndroid Build Coastguard Worker            e_msg = ('Current offset did not match upcoming physical camera! '
640*b7c941bbSAndroid Build Coastguard Worker                     f'{i} zoom: {data.result_zoom:.2f}, '
641*b7c941bbSAndroid Build Coastguard Worker                     f'next initial offset: {next_initial_offset:.1f}, '
642*b7c941bbSAndroid Build Coastguard Worker                     f'current offset: {data.aruco_offset:.1f}, '
643*b7c941bbSAndroid Build Coastguard Worker                     f'RTOL: {OFFSET_RTOL_SMOOTH_ZOOM}, ATOL: {_OFFSET_ATOL}')
644*b7c941bbSAndroid Build Coastguard Worker            logging.error(e_msg)
645*b7c941bbSAndroid Build Coastguard Worker          else:
646*b7c941bbSAndroid Build Coastguard Worker            logging.debug('Successfully matched current offset with upcoming '
647*b7c941bbSAndroid Build Coastguard Worker                          'physical camera offset')
648*b7c941bbSAndroid Build Coastguard Worker      if offset_success:
649*b7c941bbSAndroid Build Coastguard Worker        d_msg = (f'{i} zoom: {data.result_zoom:.2f}, '
650*b7c941bbSAndroid Build Coastguard Worker                 f'offset init: {initial_offset:.1f}, '
651*b7c941bbSAndroid Build Coastguard Worker                 f'offset rel: {offset_hypot_rel:.1f}, '
652*b7c941bbSAndroid Build Coastguard Worker                 f'offset dist: {data.aruco_offset:.1f}, '
653*b7c941bbSAndroid Build Coastguard Worker                 f'Zoom: {z_ratio:.1f}, '
654*b7c941bbSAndroid Build Coastguard Worker                 f'RTOL: {rel_tol}, ATOL: {_OFFSET_ATOL}')
655*b7c941bbSAndroid Build Coastguard Worker        logging.debug(d_msg)
656*b7c941bbSAndroid Build Coastguard Worker
657*b7c941bbSAndroid Build Coastguard Worker  if plot_name_stem:
658*b7c941bbSAndroid Build Coastguard Worker    plot_name = plot_name_stem.split('/')[-1].split('.')[0]
659*b7c941bbSAndroid Build Coastguard Worker    # Don't change print to logging. Used for KPI.
660*b7c941bbSAndroid Build Coastguard Worker    print(f'{plot_name}_max_rel_variation: ', max_rel_variation)
661*b7c941bbSAndroid Build Coastguard Worker    print(f'{plot_name}_max_rel_variation_zoom: ', max_rel_variation_zoom)
662*b7c941bbSAndroid Build Coastguard Worker
663*b7c941bbSAndroid Build Coastguard Worker    # Calculate RMS values
664*b7c941bbSAndroid Build Coastguard Worker    rms_z_variations = numpy.sqrt(numpy.mean(numpy.square(z_variations)))
665*b7c941bbSAndroid Build Coastguard Worker    rms_rel_variations = numpy.sqrt(numpy.mean(numpy.square(rel_variations)))
666*b7c941bbSAndroid Build Coastguard Worker
667*b7c941bbSAndroid Build Coastguard Worker    # Print RMS values
668*b7c941bbSAndroid Build Coastguard Worker    print(f'{plot_name}_rms_z_variations: ', rms_z_variations)
669*b7c941bbSAndroid Build Coastguard Worker    print(f'{plot_name}_rms_rel_variations: ', rms_rel_variations)
670*b7c941bbSAndroid Build Coastguard Worker
671*b7c941bbSAndroid Build Coastguard Worker    plot_variation(frame_numbers, z_variations, None,
672*b7c941bbSAndroid Build Coastguard Worker                   f'{plot_name_stem}_variations.png', 'Zoom Variation')
673*b7c941bbSAndroid Build Coastguard Worker    plot_variation(frame_numbers, rel_variations, radius_tols,
674*b7c941bbSAndroid Build Coastguard Worker                   f'{plot_name_stem}_relative.png', 'Relative Variation')
675*b7c941bbSAndroid Build Coastguard Worker
676*b7c941bbSAndroid Build Coastguard Worker  if offset_plot_name_stem:
677*b7c941bbSAndroid Build Coastguard Worker    plot_offset_trajectory(
678*b7c941bbSAndroid Build Coastguard Worker        [d.result_zoom for d in test_data],
679*b7c941bbSAndroid Build Coastguard Worker        offset_x_values,
680*b7c941bbSAndroid Build Coastguard Worker        offset_y_values,
681*b7c941bbSAndroid Build Coastguard Worker        hypots,
682*b7c941bbSAndroid Build Coastguard Worker        f'{offset_plot_name_stem}_offset_trajectory.gif'  # GIF animation
683*b7c941bbSAndroid Build Coastguard Worker    )
684*b7c941bbSAndroid Build Coastguard Worker
685*b7c941bbSAndroid Build Coastguard Worker  return range_success and side_success and offset_success
686*b7c941bbSAndroid Build Coastguard Worker
687*b7c941bbSAndroid Build Coastguard Worker
688*b7c941bbSAndroid Build Coastguard Workerdef verify_preview_zoom_results(test_data, size, z_max, z_min, z_step_size,
689*b7c941bbSAndroid Build Coastguard Worker                                plot_name_stem):
690*b7c941bbSAndroid Build Coastguard Worker  """Verify that the output images' zoom level reflects the correct zoom ratios.
691*b7c941bbSAndroid Build Coastguard Worker
692*b7c941bbSAndroid Build Coastguard Worker  This test verifies that the center and radius of the circles in the output
693*b7c941bbSAndroid Build Coastguard Worker  images reflects the zoom ratios being set. The larger the zoom ratio, the
694*b7c941bbSAndroid Build Coastguard Worker  larger the circle. And the distance from the center of the circle to the
695*b7c941bbSAndroid Build Coastguard Worker  center of the image is proportional to the zoom ratio as well. Verifies
696*b7c941bbSAndroid Build Coastguard Worker  that circles are detected throughout the zoom range.
697*b7c941bbSAndroid Build Coastguard Worker
698*b7c941bbSAndroid Build Coastguard Worker  Args:
699*b7c941bbSAndroid Build Coastguard Worker    test_data: Iterable[ZoomTestData]
700*b7c941bbSAndroid Build Coastguard Worker    size: array; the width and height of the images
701*b7c941bbSAndroid Build Coastguard Worker    z_max: float; the maximum zoom ratio being tested
702*b7c941bbSAndroid Build Coastguard Worker    z_min: float; the minimum zoom ratio being tested
703*b7c941bbSAndroid Build Coastguard Worker    z_step_size: float; zoom step size to zoom from z_min to z_max
704*b7c941bbSAndroid Build Coastguard Worker    plot_name_stem: str; log path and name of the plot
705*b7c941bbSAndroid Build Coastguard Worker
706*b7c941bbSAndroid Build Coastguard Worker  Returns:
707*b7c941bbSAndroid Build Coastguard Worker    Boolean whether the test passes (True) or not (False)
708*b7c941bbSAndroid Build Coastguard Worker  """
709*b7c941bbSAndroid Build Coastguard Worker  test_success = True
710*b7c941bbSAndroid Build Coastguard Worker
711*b7c941bbSAndroid Build Coastguard Worker  test_data_zoom_values = [v.result_zoom for v in test_data]
712*b7c941bbSAndroid Build Coastguard Worker  results_z_max = max(test_data_zoom_values)
713*b7c941bbSAndroid Build Coastguard Worker  results_z_min = min(test_data_zoom_values)
714*b7c941bbSAndroid Build Coastguard Worker  logging.debug('capture result: min zoom: %.2f vs max zoom: %.2f',
715*b7c941bbSAndroid Build Coastguard Worker                results_z_min, results_z_max)
716*b7c941bbSAndroid Build Coastguard Worker
717*b7c941bbSAndroid Build Coastguard Worker  # check if max zoom in capture result close to requested zoom range
718*b7c941bbSAndroid Build Coastguard Worker  if (math.isclose(results_z_max, z_max, rel_tol=PRV_Z_RTOL) or
719*b7c941bbSAndroid Build Coastguard Worker      math.isclose(results_z_max, z_max - z_step_size, rel_tol=PRV_Z_RTOL)):
720*b7c941bbSAndroid Build Coastguard Worker    d_msg = (f'results_z_max = {results_z_max:.2f} is close to requested '
721*b7c941bbSAndroid Build Coastguard Worker             f'z_max = {z_max:.2f} or z_max-step = {z_max-z_step_size:.2f} '
722*b7c941bbSAndroid Build Coastguard Worker             f'by {PRV_Z_RTOL:.2f} Tol')
723*b7c941bbSAndroid Build Coastguard Worker    logging.debug(d_msg)
724*b7c941bbSAndroid Build Coastguard Worker  else:
725*b7c941bbSAndroid Build Coastguard Worker    test_success = False
726*b7c941bbSAndroid Build Coastguard Worker    e_msg = (f'Max zoom ratio {results_z_max:.4f} in capture results '
727*b7c941bbSAndroid Build Coastguard Worker             f'not close to {z_max:.2f} and '
728*b7c941bbSAndroid Build Coastguard Worker             f'z_max-step = {z_max-z_step_size:.2f} by {PRV_Z_RTOL:.2f} '
729*b7c941bbSAndroid Build Coastguard Worker             f'tolerance.')
730*b7c941bbSAndroid Build Coastguard Worker    logging.error(e_msg)
731*b7c941bbSAndroid Build Coastguard Worker
732*b7c941bbSAndroid Build Coastguard Worker  if math.isclose(results_z_min, z_min, rel_tol=PRV_Z_RTOL):
733*b7c941bbSAndroid Build Coastguard Worker    d_msg = (f'results_z_min = {results_z_min:.2f} is close to requested '
734*b7c941bbSAndroid Build Coastguard Worker             f'z_min = {z_min:.2f} by {PRV_Z_RTOL:.2f} Tol')
735*b7c941bbSAndroid Build Coastguard Worker    logging.debug(d_msg)
736*b7c941bbSAndroid Build Coastguard Worker  else:
737*b7c941bbSAndroid Build Coastguard Worker    test_success = False
738*b7c941bbSAndroid Build Coastguard Worker    e_msg = (f'Min zoom ratio {results_z_min:.4f} in capture results '
739*b7c941bbSAndroid Build Coastguard Worker             f'not close to {z_min:.2f} by {PRV_Z_RTOL:.2f} tolerance.')
740*b7c941bbSAndroid Build Coastguard Worker    logging.error(e_msg)
741*b7c941bbSAndroid Build Coastguard Worker
742*b7c941bbSAndroid Build Coastguard Worker  return test_success and verify_zoom_data(
743*b7c941bbSAndroid Build Coastguard Worker      test_data, size, plot_name_stem=plot_name_stem)
744*b7c941bbSAndroid Build Coastguard Worker
745*b7c941bbSAndroid Build Coastguard Worker
746*b7c941bbSAndroid Build Coastguard Workerdef get_preview_zoom_params(zoom_range, steps):
747*b7c941bbSAndroid Build Coastguard Worker  """Returns zoom min, max, step_size based on zoom range and steps.
748*b7c941bbSAndroid Build Coastguard Worker
749*b7c941bbSAndroid Build Coastguard Worker  Determine zoom min, max, step_size based on zoom range, steps.
750*b7c941bbSAndroid Build Coastguard Worker  Zoom max is capped due to current ITS box size limitation.
751*b7c941bbSAndroid Build Coastguard Worker
752*b7c941bbSAndroid Build Coastguard Worker  Args:
753*b7c941bbSAndroid Build Coastguard Worker    zoom_range: [float,float]; Camera's zoom range
754*b7c941bbSAndroid Build Coastguard Worker    steps: int; number of steps
755*b7c941bbSAndroid Build Coastguard Worker
756*b7c941bbSAndroid Build Coastguard Worker  Returns:
757*b7c941bbSAndroid Build Coastguard Worker    zoom_min: minimum zoom
758*b7c941bbSAndroid Build Coastguard Worker    zoom_max: maximum zoom
759*b7c941bbSAndroid Build Coastguard Worker    zoom_step_size: size of zoom steps
760*b7c941bbSAndroid Build Coastguard Worker  """
761*b7c941bbSAndroid Build Coastguard Worker  # Determine test zoom range
762*b7c941bbSAndroid Build Coastguard Worker  logging.debug('z_range = %s', str(zoom_range))
763*b7c941bbSAndroid Build Coastguard Worker  zoom_min, zoom_max = float(zoom_range[0]), float(zoom_range[1])
764*b7c941bbSAndroid Build Coastguard Worker  zoom_max = min(zoom_max, ZOOM_MAX_THRESH * zoom_min)
765*b7c941bbSAndroid Build Coastguard Worker
766*b7c941bbSAndroid Build Coastguard Worker  zoom_step_size = (zoom_max-zoom_min) / (steps-1)
767*b7c941bbSAndroid Build Coastguard Worker  logging.debug('zoomRatioRange = %s z_min = %f z_max = %f z_stepSize = %f',
768*b7c941bbSAndroid Build Coastguard Worker                str(zoom_range), zoom_min, zoom_max, zoom_step_size)
769*b7c941bbSAndroid Build Coastguard Worker
770*b7c941bbSAndroid Build Coastguard Worker  return zoom_min, zoom_max, zoom_step_size
771*b7c941bbSAndroid Build Coastguard Worker
772*b7c941bbSAndroid Build Coastguard Worker
773*b7c941bbSAndroid Build Coastguard Workerdef plot_variation(frame_numbers, variations, tolerances, plot_name, ylabel):
774*b7c941bbSAndroid Build Coastguard Worker  """Plots a variation against frame numbers with corresponding tolerances.
775*b7c941bbSAndroid Build Coastguard Worker
776*b7c941bbSAndroid Build Coastguard Worker  Args:
777*b7c941bbSAndroid Build Coastguard Worker    frame_numbers: List of frame numbers.
778*b7c941bbSAndroid Build Coastguard Worker    variations: List of variations.
779*b7c941bbSAndroid Build Coastguard Worker    tolerances: List of tolerances corresponding to each variation.
780*b7c941bbSAndroid Build Coastguard Worker    plot_name: Name for the plot file.
781*b7c941bbSAndroid Build Coastguard Worker    ylabel: Label for the y-axis.
782*b7c941bbSAndroid Build Coastguard Worker  """
783*b7c941bbSAndroid Build Coastguard Worker
784*b7c941bbSAndroid Build Coastguard Worker  plt.figure(figsize=(40, 10))
785*b7c941bbSAndroid Build Coastguard Worker
786*b7c941bbSAndroid Build Coastguard Worker  plt.scatter(frame_numbers, variations, marker='o', linestyle='-',
787*b7c941bbSAndroid Build Coastguard Worker              color='blue', label=ylabel)
788*b7c941bbSAndroid Build Coastguard Worker
789*b7c941bbSAndroid Build Coastguard Worker  if tolerances:
790*b7c941bbSAndroid Build Coastguard Worker    plt.plot(frame_numbers, tolerances, linestyle='--', color='red',
791*b7c941bbSAndroid Build Coastguard Worker             label='Tolerance')
792*b7c941bbSAndroid Build Coastguard Worker
793*b7c941bbSAndroid Build Coastguard Worker  plt.xlabel('Frame Number', fontsize=12)
794*b7c941bbSAndroid Build Coastguard Worker  plt.ylabel(ylabel, fontsize=12)
795*b7c941bbSAndroid Build Coastguard Worker  plt.title(f'{ylabel} vs. Frame Number', fontsize=14)
796*b7c941bbSAndroid Build Coastguard Worker
797*b7c941bbSAndroid Build Coastguard Worker  plt.legend()
798*b7c941bbSAndroid Build Coastguard Worker
799*b7c941bbSAndroid Build Coastguard Worker  plt.grid(axis='y', linestyle='--')
800*b7c941bbSAndroid Build Coastguard Worker  plt.savefig(plot_name)
801*b7c941bbSAndroid Build Coastguard Worker  plt.close()
802*b7c941bbSAndroid Build Coastguard Worker
803*b7c941bbSAndroid Build Coastguard Worker
804*b7c941bbSAndroid Build Coastguard Workerdef plot_offset_trajectory(
805*b7c941bbSAndroid Build Coastguard Worker    zooms, x_offsets, y_offsets, hypots, plot_name):
806*b7c941bbSAndroid Build Coastguard Worker  """Plot an animation describing offset drift for each zoom ratio.
807*b7c941bbSAndroid Build Coastguard Worker
808*b7c941bbSAndroid Build Coastguard Worker  Args:
809*b7c941bbSAndroid Build Coastguard Worker    zooms: Iterable[float]; zoom ratios corresponding to each offset.
810*b7c941bbSAndroid Build Coastguard Worker    x_offsets: Iterable[float]; x-axis offsets.
811*b7c941bbSAndroid Build Coastguard Worker    y_offsets: Iterable[float]; y-axis offsets.
812*b7c941bbSAndroid Build Coastguard Worker    hypots: Iterable[float]; offset hypotenuses (distances from image center).
813*b7c941bbSAndroid Build Coastguard Worker    plot_name: Plot name with path to save the plot.
814*b7c941bbSAndroid Build Coastguard Worker  """
815*b7c941bbSAndroid Build Coastguard Worker  fig, (ax1, ax2) = plt.subplots(1, 2, constrained_layout=True)
816*b7c941bbSAndroid Build Coastguard Worker  fig.suptitle('Zoom Offset Trajectory')
817*b7c941bbSAndroid Build Coastguard Worker  scatter = ax1.scatter([], [], c='blue', marker='o')
818*b7c941bbSAndroid Build Coastguard Worker  line, = ax1.plot([], [], c='blue', linestyle='dashed')
819*b7c941bbSAndroid Build Coastguard Worker
820*b7c941bbSAndroid Build Coastguard Worker  # Preset axes limits, since data is added frame by frame (no initial data).
821*b7c941bbSAndroid Build Coastguard Worker  ax1.set_xlim(min(x_offsets), max(x_offsets), auto=True)
822*b7c941bbSAndroid Build Coastguard Worker  ax1.set_ylim(min(y_offsets), max(y_offsets), auto=True)
823*b7c941bbSAndroid Build Coastguard Worker
824*b7c941bbSAndroid Build Coastguard Worker  ax1.set_title('Offset (x, y) by Zoom Ratio')
825*b7c941bbSAndroid Build Coastguard Worker  ax1.set_xlabel('x')
826*b7c941bbSAndroid Build Coastguard Worker  ax1.set_ylabel('y')
827*b7c941bbSAndroid Build Coastguard Worker
828*b7c941bbSAndroid Build Coastguard Worker  # Function to animate each frame. Each frame corresponds to a capture/zoom.
829*b7c941bbSAndroid Build Coastguard Worker  def animate(i):
830*b7c941bbSAndroid Build Coastguard Worker    scatter.set_offsets((x_offsets[i], y_offsets[i]))
831*b7c941bbSAndroid Build Coastguard Worker    line.set_data(x_offsets[:i+1], y_offsets[:i+1])
832*b7c941bbSAndroid Build Coastguard Worker    ax1.set_title(f'Zoom: {zooms[i]:.3f}')
833*b7c941bbSAndroid Build Coastguard Worker    return scatter, line
834*b7c941bbSAndroid Build Coastguard Worker
835*b7c941bbSAndroid Build Coastguard Worker  ani = animation.FuncAnimation(
836*b7c941bbSAndroid Build Coastguard Worker      fig, animate, repeat=True, frames=len(hypots),
837*b7c941bbSAndroid Build Coastguard Worker      interval=_OFFSET_PLOT_INTERVAL
838*b7c941bbSAndroid Build Coastguard Worker  )
839*b7c941bbSAndroid Build Coastguard Worker
840*b7c941bbSAndroid Build Coastguard Worker  ax2.xaxis.set_major_locator(ticker.MultipleLocator(1))  # ticker every 1.0x.
841*b7c941bbSAndroid Build Coastguard Worker  ax2.plot(zooms, hypots, '-bo')
842*b7c941bbSAndroid Build Coastguard Worker  ax2.set_title('Offset Distance vs. Zoom Ratio')
843*b7c941bbSAndroid Build Coastguard Worker  ax2.set_xlabel('Zoom Ratio')
844*b7c941bbSAndroid Build Coastguard Worker  ax2.set_ylabel('Offset (pixels)')
845*b7c941bbSAndroid Build Coastguard Worker
846*b7c941bbSAndroid Build Coastguard Worker  writer = animation.PillowWriter(fps=_OFFSET_PLOT_FPS)
847*b7c941bbSAndroid Build Coastguard Worker  ani.save(plot_name, writer=writer)
848