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