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