blob: de0a110c82ab4e0d7417b4181ceacb7fab13cc5a [file] [log] [blame]
# Copyright 2023 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Utility functions for zoom capture.
"""
from collections.abc import Iterable
import dataclasses
import logging
import math
from typing import Optional
import cv2
from matplotlib import animation
from matplotlib import ticker
import matplotlib.pyplot as plt
import numpy
from PIL import Image
import camera_properties_utils
import capture_request_utils
import image_processing_utils
import opencv_processing_utils
_CIRCLE_COLOR = 0 # [0: black, 255: white]
_CIRCLE_AR_RTOL = 0.15 # contour width vs height (aspect ratio)
_SMOOTH_ZOOM_OFFSET_MONOTONICITY_ATOL = 25 # number of pixels
_PREVIEW_SMOOTH_ZOOM_OFFSET_MONOTONICITY_ATOL = 75 # number of pixels
_CIRCLISH_RTOL = 0.05 # contour area vs ideal circle area pi*((w+h)/4)**2
_CONTOUR_AREA_LOGGING_THRESH = 0.8 # logging tol to cut down spam in log file
_CV2_LINE_THICKNESS = 3 # line thickness for drawing on images
_CV2_RED = (255, 0, 0) # color in cv2 to draw lines
_MIN_AREA_RATIO = 0.00013 # Found empirically with partners
_MIN_CIRCLE_PTS = 25
_MIN_FOCUS_DIST_TOL = 0.80 # allow charts a little closer than min
_OFFSET_ATOL = 15 # number of pixels
_OFFSET_PLOT_FPS = 2
_OFFSET_PLOT_INTERVAL = 400 # delay between frames in milliseconds.
_OFFSET_RTOL_MIN_FD = 0.30
_RADIUS_RTOL_MIN_FD = 0.15
DEFAULT_FOV_RATIO = 1 # ratio of sub camera's fov over logical camera's fov
JPEG_STR = 'jpg'
OFFSET_RTOL = 0.15
OFFSET_RTOL_SMOOTH_ZOOM = 0.5 # generous RTOL paired with other offset checks
OFFSET_ATOL_SMOOTH_ZOOM = 75 # generous ATOL paired with other offset checks
PREFERRED_BASE_ZOOM_RATIO = 1 # Preferred base image for zoom data verification
PREFERRED_BASE_ZOOM_RATIO_RTOL = 0.1
PRV_Z_RTOL = 0.02 # 2% variation of zoom ratio between request and result
RADIUS_RTOL = 0.15
ZOOM_MAX_THRESH = 9.0 # TODO: b/368666244 - reduce marker size and use 10.0
ZOOM_MIN_THRESH = 2.0
ZOOM_RTOL = 0.01 # variation of zoom ratio due to floating point
@dataclasses.dataclass
class ZoomTestData:
"""Class to store zoom-related metadata for a capture."""
result_zoom: float
radius_tol: float
offset_tol: float
focal_length: Optional[float] = None
# (x, y) coordinates of ArUco marker corners in clockwise order from top left.
aruco_corners: Optional[Iterable[float]] = None
aruco_offset: Optional[float] = None
physical_id: int = dataclasses.field(default=None)
def get_test_tols_and_cap_size(cam, props, chart_distance, debug):
"""Determine the tolerance per camera based on test rig and camera params.
Cameras are pre-filtered to only include supportable cameras.
Supportable cameras are: YUV(RGB)
Args:
cam: camera object
props: dict; physical camera properties dictionary
chart_distance: float; distance to chart in cm
debug: boolean; log additional data
Returns:
dict of TOLs with camera focal length as key
largest common size across all cameras
"""
ids = camera_properties_utils.logical_multi_camera_physical_ids(props)
physical_props = {}
physical_ids = []
for i in ids:
physical_props[i] = cam.get_camera_properties_by_id(i)
# find YUV capable physical cameras
if camera_properties_utils.backward_compatible(physical_props[i]):
physical_ids.append(i)
# find physical camera focal lengths that work well with rig
chart_distance_m = abs(chart_distance)/100 # convert CM to M
test_tols = {}
test_yuv_sizes = []
for i in physical_ids:
yuv_sizes = capture_request_utils.get_available_output_sizes(
'yuv', physical_props[i])
test_yuv_sizes.append(yuv_sizes)
if debug:
logging.debug('cam[%s] yuv sizes: %s', i, str(yuv_sizes))
# determine if minimum focus distance is less than rig depth
min_fd = physical_props[i]['android.lens.info.minimumFocusDistance']
for fl in physical_props[i]['android.lens.info.availableFocalLengths']:
logging.debug('cam[%s] min_fd: %.3f (diopters), fl: %.2f', i, min_fd, fl)
if (math.isclose(min_fd, 0.0, rel_tol=1E-6) or # fixed focus
(1.0/min_fd < chart_distance_m*_MIN_FOCUS_DIST_TOL)):
test_tols[fl] = (RADIUS_RTOL, OFFSET_RTOL)
else:
test_tols[fl] = (_RADIUS_RTOL_MIN_FD, _OFFSET_RTOL_MIN_FD)
logging.debug('loosening RTOL for cam[%s]: '
'min focus distance too large.', i)
# find intersection of formats for max common format
common_sizes = list(set.intersection(*[set(list) for list in test_yuv_sizes]))
if debug:
logging.debug('common_fmt: %s', max(common_sizes))
return test_tols, max(common_sizes)
def find_center_circle(
img, img_name, size, zoom_ratio, min_zoom_ratio,
expected_color=_CIRCLE_COLOR, circle_ar_rtol=_CIRCLE_AR_RTOL,
circlish_rtol=_CIRCLISH_RTOL, min_circle_pts=_MIN_CIRCLE_PTS,
fov_ratio=DEFAULT_FOV_RATIO, debug=False, draw_color=_CV2_RED,
write_img=True):
"""Find circle closest to image center for scene with multiple circles.
Finds all contours in the image. Rejects those too small and not enough
points to qualify as a circle. The remaining contours must have center
point of color=color and are sorted based on distance from the center
of the image. The contour closest to the center of the image is returned.
If circle is not found due to zoom ratio being larger than ZOOM_MAX_THRESH
or the circle being cropped, None is returned.
Note: hierarchy is not used as the hierarchy for black circles changes
as the zoom level changes.
Args:
img: numpy img array with pixel values in [0,255]
img_name: str file name for saved image
size: [width, height] of the image
zoom_ratio: zoom_ratio for the particular capture
min_zoom_ratio: min_zoom_ratio supported by the camera device
expected_color: int 0 --> black, 255 --> white
circle_ar_rtol: float aspect ratio relative tolerance
circlish_rtol: float contour area vs ideal circle area pi*((w+h)/4)**2
min_circle_pts: int minimum number of points to define a circle
fov_ratio: ratio of sub camera over logical camera's field of view
debug: bool to save extra data
draw_color: cv2 color in RGB to draw circle and circle center on the image
write_img: bool: True - save image with circle and center
False - don't save image.
Returns:
circle: [center_x, center_y, radius]
"""
width, height = size
min_area = (
_MIN_AREA_RATIO * width * height * zoom_ratio * zoom_ratio * fov_ratio)
# create a copy of image to avoid modification on the original image since
# image_processing_utils.convert_image_to_uint8 uses mutable np array methods
if debug:
img = numpy.ndarray.copy(img)
# convert [0, 1] image to [0, 255] and cast as uint8
if img.dtype != numpy.uint8:
img = image_processing_utils.convert_image_to_uint8(img)
# gray scale & otsu threshold to binarize the image
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, img_bw = cv2.threshold(
numpy.uint8(gray), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# use OpenCV to find contours (connected components)
contours = opencv_processing_utils.find_all_contours(255-img_bw)
# write copy of image for debug purposes
if debug:
img_copy_name = img_name.split('.')[0] + '_copy.jpg'
Image.fromarray((img_bw).astype(numpy.uint8)).save(img_copy_name)
# check contours and find the best circle candidates
circles = []
img_ctr = [gray.shape[1] // 2, gray.shape[0] // 2]
logging.debug('img center x,y: %d, %d', img_ctr[0], img_ctr[1])
logging.debug('min area: %d, min circle pts: %d', min_area, min_circle_pts)
logging.debug('circlish_rtol: %.3f', circlish_rtol)
for contour in contours:
area = cv2.contourArea(contour)
if area > min_area * _CONTOUR_AREA_LOGGING_THRESH: # skip tiny contours
logging.debug('area: %d, min_area: %d, num_pts: %d, min_circle_pts: %d',
area, min_area, len(contour), min_circle_pts)
if area > min_area and len(contour) >= min_circle_pts:
shape = opencv_processing_utils.component_shape(contour)
radius = (shape['width'] + shape['height']) / 4
circle_color = img_bw[shape['cty']][shape['ctx']]
circlish = round((math.pi * radius**2) / area, 4)
logging.debug('color: %s, circlish: %.2f, WxH: %dx%d',
circle_color, circlish, shape['width'], shape['height'])
if (circle_color == expected_color and
math.isclose(1, circlish, rel_tol=circlish_rtol) and
math.isclose(shape['width'], shape['height'],
rel_tol=circle_ar_rtol)):
logging.debug('circle found: r: %.2f, area: %.2f\n', radius, area)
circles.append([shape['ctx'], shape['cty'], radius, circlish, area])
else:
logging.debug('circle rejected: bad color, circlish or aspect ratio\n')
if not circles:
zoom_ratio_value = zoom_ratio / min_zoom_ratio
if zoom_ratio_value >= ZOOM_MAX_THRESH:
logging.debug('No circle was detected, but zoom %.2f exceeds'
' maximum zoom threshold', zoom_ratio_value)
return None
else:
raise AssertionError(
'No circle detected for zoom ratio <= '
f'{ZOOM_MAX_THRESH}. '
'Take pictures according to instructions carefully!')
else:
logging.debug('num of circles found: %s', len(circles))
if debug:
logging.debug('circles [x, y, r, pi*r**2/area, area]: %s', str(circles))
# find circle closest to center
circle = min(
circles, key=lambda x: math.hypot(x[0] - img_ctr[0], x[1] - img_ctr[1]))
# check if circle is cropped because of zoom factor
if opencv_processing_utils.is_circle_cropped(circle, size):
logging.debug('zoom %.2f is too large! Skip further captures', zoom_ratio)
return None
# mark image center
size = gray.shape
m_x, m_y = size[1] // 2, size[0] // 2
marker_size = _CV2_LINE_THICKNESS * 10
cv2.drawMarker(img, (m_x, m_y), draw_color, markerType=cv2.MARKER_CROSS,
markerSize=marker_size, thickness=_CV2_LINE_THICKNESS)
# add circle to saved image
center_i = (int(round(circle[0], 0)), int(round(circle[1], 0)))
radius_i = int(round(circle[2], 0))
cv2.circle(img, center_i, radius_i, draw_color, _CV2_LINE_THICKNESS)
if write_img:
image_processing_utils.write_image(img / 255.0, img_name)
return circle
def preview_zoom_data_to_string(test_data):
"""Returns formatted string from test_data.
Floats are capped at 2 floating points.
Args:
test_data: ZoomTestData with relevant test data.
Returns:
Formatted String
"""
output = []
for key, value in dataclasses.asdict(test_data).items():
if isinstance(value, float):
output.append(f'{key}: {value:.2f}')
elif isinstance(value, list):
output.append(
f"{key}: [{', '.join([f'{item:.2f}' for item in value])}]")
else:
output.append(f'{key}: {value}')
return ', '.join(output)
def _get_aruco_marker_x_y_offset(aruco_corners, size):
"""Get the x and y distances from the ArUco marker to the image center.
Args:
aruco_corners: list of 4 Iterables, each tuple is a (x, y) coordinate of a
corner.
size: Iterable; the width and height of the images.
Returns:
The x and y distances from the ArUco marker to the center of the image.
"""
aruco_marker_x, aruco_marker_y = opencv_processing_utils.get_aruco_center(
aruco_corners)
return aruco_marker_x - size[0] // 2, aruco_marker_y - size[1] // 2
def _get_aruco_marker_offset(aruco_corners, size):
"""Get the distance from the chosen ArUco marker to the center of the image.
Args:
aruco_corners: list of 4 Iterables, each tuple is a (x, y) coordinate of a
corner.
size: Iterable; the width and height of the images.
Returns:
The distance from the ArUco marker to the center of the image.
"""
return math.hypot(*_get_aruco_marker_x_y_offset(aruco_corners, size))
def _get_shortest_focal_length(props):
"""Return the first available focal length from properties."""
return props['android.lens.info.availableFocalLengths'][0]
def _get_average_offset(shared_id, aruco_ids, aruco_corners, size):
"""Get the average offset a given marker to the image center.
Args:
shared_id: ID of the given marker to find the average offset.
aruco_ids: nested Iterables of ArUco marker IDs.
aruco_corners: nested Iterables of ArUco marker corners.
size: size of the image to calculate image center.
Returns:
The average offset from the given marker to the image center.
"""
offsets = []
for ids, corners in zip(aruco_ids, aruco_corners):
offsets.append(
_get_average_offset_from_single_capture(
shared_id, ids, corners, size))
return numpy.mean(offsets)
def _get_average_offset_from_single_capture(
shared_id, ids, corners, size):
"""Get the average offset a given marker to a known image's center.
Args:
shared_id: ID of the given marker to find the average offset.
ids: Iterable of ArUco marker IDs for single capture test data.
corners: Iterable of ArUco marker corners for single capture test data.
size: size of the image to calculate image center.
Returns:
The average offset from the given marker to the image center.
"""
corresponding_corners = corners[numpy.where(ids == shared_id)[0][0]]
return _get_aruco_marker_offset(corresponding_corners, size)
def _are_values_non_decreasing(values, abs_tol=0):
"""Returns True if any values are not decreasing with absolute tolerance."""
return all(x < y + abs_tol for x, y in zip(values, values[1:]))
def _are_values_non_increasing(values, abs_tol=0):
"""Returns True if any values are not increasing with absolute tolerance."""
return all(x > y - abs_tol for x, y in zip(values, values[1:]))
def _verify_offset_monotonicity(offsets, monotonicity_atol):
"""Returns if values continuously increase or decrease with tolerance."""
return (
_are_values_non_decreasing(
offsets, monotonicity_atol) or
_are_values_non_increasing(
offsets, monotonicity_atol)
)
def update_zoom_test_data_with_shared_aruco_marker(
test_data, aruco_ids, aruco_corners, size):
"""Update test_data in place with a shared ArUco marker if available.
Iterates through the list of aruco_ids and aruco_corners to find the shared
ArUco marker that is closest to the center across all captures. If found,
updates the test_data with the shared marker and its offset from the
image center.
Args:
test_data: list of ZoomTestData.
aruco_ids: nested Iterables of ArUco marker IDs.
aruco_corners: nested Iterables of ArUco marker corners.
size: Iterable; the width and height of the images.
"""
shared_ids = set(list(aruco_ids[0]))
for ids in aruco_ids[1:]:
shared_ids.intersection_update(list(ids))
# Choose closest shared marker to center of transition image if possible.
if shared_ids:
for i, (ids, corners) in enumerate(zip(aruco_ids, aruco_corners)):
if test_data[i].physical_id != test_data[0].physical_id:
transition_aruco_ids = ids
transition_aruco_corners = corners
shared_id = min(
shared_ids,
key=lambda i: _get_average_offset_from_single_capture(
i, transition_aruco_ids, transition_aruco_corners, size)
)
break
else:
shared_id = min(
shared_ids,
key=lambda i: _get_average_offset(i, aruco_ids, aruco_corners, size)
)
else:
raise AssertionError('No shared ArUco marker found across all captures.')
logging.debug('Using shared aruco ID %d', shared_id)
for i, (ids, corners) in enumerate(zip(aruco_ids, aruco_corners)):
index = numpy.where(ids == shared_id)[0][0]
corresponding_corners = corners[index]
logging.debug('Corners of shared ID: %s', corresponding_corners)
test_data[i].aruco_corners = corresponding_corners
test_data[i].aruco_offset = (
_get_aruco_marker_offset(
corresponding_corners, size
)
)
def verify_zoom_results(test_data, size, z_max, z_min,
offset_plot_name_stem=None):
"""Verify that the output images' zoom level reflects the correct zoom ratios.
This test verifies that the center and radius of the circles in the output
images reflects the zoom ratios being set. The larger the zoom ratio, the
larger the circle. And the distance from the center of the circle to the
center of the image is proportional to the zoom ratio as well.
Args:
test_data: Iterable[ZoomTestData]
size: array; the width and height of the images
z_max: float; the maximum zoom ratio being tested
z_min: float; the minimum zoom ratio being tested
offset_plot_name_stem: Optional[str]; log path and name of the offset plot
Returns:
Boolean whether the test passes (True) or not (False)
"""
# assert some range is tested before circles get too big
test_success = True
zoom_max_thresh = ZOOM_MAX_THRESH
z_max_ratio = z_max / z_min
if z_max_ratio < ZOOM_MAX_THRESH:
zoom_max_thresh = z_max_ratio
# handle capture orders like [1, 0.5, 1.5, 2...]
test_data_zoom_values = [v.result_zoom for v in test_data]
test_data_max_z = max(test_data_zoom_values) / min(test_data_zoom_values)
logging.debug('test zoom ratio max: %.2f vs threshold %.2f',
test_data_max_z, zoom_max_thresh)
if not math.isclose(
test_data_max_z, zoom_max_thresh, rel_tol=ZOOM_RTOL):
test_success = False
e_msg = (f'Max zoom ratio tested: {test_data_max_z:.4f}, '
f'range advertised min: {z_min}, max: {z_max} '
f'THRESH: {zoom_max_thresh + ZOOM_RTOL}')
logging.error(e_msg)
return test_success and verify_zoom_data(
test_data, size, offset_plot_name_stem=offset_plot_name_stem)
def verify_zoom_data(test_data, size, plot_name_stem=None,
offset_plot_name_stem=None,
monotonicity_atol=_SMOOTH_ZOOM_OFFSET_MONOTONICITY_ATOL,
number_of_cameras_to_test=0):
"""Verify output images' zoom level reflects the correct zoom ratios.
This test ensures accurate zoom functionality by verifying that ArUco marker
dimensions and positions in the output images scale correctly with applied
zoom ratios. Specifically, the ArUco marker side must increase
proportionally to the zoom. The marker's center offset from the image center
should either scale proportionally with the zoom or converge towards the
offset of the initial capture from the physical camera, particularly after
a camera switch.
Args:
test_data: Iterable[ZoomTestData]
size: array; the width and height of the images
plot_name_stem: Optional[str]; log path and name of the plot
offset_plot_name_stem: Optional[str]; log path and name of the offset plot
monotonicity_atol: Optional[float]; absolute tolerance for offset
monotonicity
number_of_cameras_to_test: [Optional][int]; minimum cameras in ZoomTestData
Returns:
Boolean whether the test passes (True) or not (False)
"""
range_success = True
side_success = True
offset_success = True
used_smooth_offset = False
# assert that multiple cameras were tested where applicable
ids_tested = set([v.physical_id for v in test_data])
if len(ids_tested) < number_of_cameras_to_test:
range_success = False
logging.error('Expected at least %d physical cameras tested, '
'found IDs: %s', number_of_cameras_to_test, ids_tested)
# initialize relative size w/ zoom[0] for diff zoom ratio checks
side_0 = opencv_processing_utils.get_aruco_marker_side_length(
test_data[0].aruco_corners)
z_0 = float(test_data[0].result_zoom)
# use 1x ~ 1.1x data as base image if available
if z_0 < PREFERRED_BASE_ZOOM_RATIO:
for data in test_data:
if (data.result_zoom >= PREFERRED_BASE_ZOOM_RATIO and
math.isclose(data.result_zoom, PREFERRED_BASE_ZOOM_RATIO,
rel_tol=PREFERRED_BASE_ZOOM_RATIO_RTOL)):
side_0 = opencv_processing_utils.get_aruco_marker_side_length(
data.aruco_corners)
z_0 = float(data.result_zoom)
break
logging.debug('Initial zoom: %.3f, Aruco marker length: %.3f', z_0, side_0)
if plot_name_stem:
frame_numbers = []
z_variations = []
rel_variations = []
radius_tols = []
max_rel_variation = None
max_rel_variation_zoom = None
offset_x_values = []
offset_y_values = []
hypots = []
id_to_next_offset_and_zoom = {}
offsets_while_transitioning = []
previous_id = test_data[0].physical_id
# First pass to get transition points
for i, data in enumerate(test_data):
if i == 0:
continue
if test_data[i-1].physical_id != data.physical_id:
id_to_next_offset_and_zoom[previous_id] = (
data.aruco_offset, data.result_zoom
)
previous_id = data.physical_id
initial_offset = test_data[0].aruco_offset
initial_zoom = test_data[0].result_zoom
# Second pass to check offset correctness
for i, data in enumerate(test_data):
logging.debug(' ') # add blank line between frames
logging.debug('Frame # %d: {%s}', i, preview_zoom_data_to_string(data))
logging.debug('Zoom: %.2f, physical ID: %s',
data.result_zoom, data.physical_id)
offset_x, offset_y = _get_aruco_marker_x_y_offset(data.aruco_corners, size)
offset_x_values.append(offset_x)
offset_y_values.append(offset_y)
z_ratio = data.result_zoom / z_0
logged_data = False
# check relative size against zoom[0]
current_side = opencv_processing_utils.get_aruco_marker_side_length(
data.aruco_corners)
side_ratio = current_side / side_0
# Calculate variations
z_variation = z_ratio - side_ratio
relative_variation = abs(z_variation) / max(abs(z_ratio), abs(side_ratio))
# Store values for plotting
if plot_name_stem:
frame_numbers.append(i)
z_variations.append(z_variation)
rel_variations.append(relative_variation)
radius_tols.append(data.radius_tol)
if max_rel_variation is None or relative_variation > max_rel_variation:
max_rel_variation = relative_variation
max_rel_variation_zoom = data.result_zoom
logging.debug('r ratio req: %.3f, measured: %.3f',
z_ratio, side_ratio)
msg = (
f'Marker side ratio: result({data.result_zoom:.3f}/{z_0:.3f}):'
f' {z_ratio:.3f}, marker({current_side:.3f}/{side_0:.3f}):'
f' {side_ratio:.3f}, RTOL: {data.radius_tol}'
)
if not math.isclose(z_ratio, side_ratio, rel_tol=data.radius_tol):
side_success = False
logging.error(msg)
else:
logging.debug(msg)
# check relative offset against init vals w/ no focal length change
# set init values for first capture or change in physical cam focal length
hypots.append(data.aruco_offset)
if i == 0:
continue
if test_data[i-1].physical_id != data.physical_id:
initial_zoom = float(data.result_zoom)
initial_offset = data.aruco_offset
d_msg = (f'-- init {i} zoom: {data.result_zoom:.2f}, '
f'Initial offset: {initial_offset:.1f}, '
f'Zoom: {z_ratio:.1f} ')
logging.debug(d_msg)
if offsets_while_transitioning:
logging.debug('Offsets while transitioning: %s',
offsets_while_transitioning)
if used_smooth_offset and not _verify_offset_monotonicity(
offsets_while_transitioning, monotonicity_atol):
logging.error('Offsets %s are not monotonic',
offsets_while_transitioning)
offset_success = False
offsets_while_transitioning.clear()
else:
offsets_while_transitioning.append(data.aruco_offset)
z_ratio = data.result_zoom / initial_zoom
# Expected offset based on the current zoom ratio and initial offset
offset_hypot_rel = data.aruco_offset / z_ratio
rel_tol = data.offset_tol
msg = (f'Frame # {i} zoom: {data.result_zoom:.2f}, '
f'Baseline offset value: {initial_offset:.4f}, '
f'Expected offset: {offset_hypot_rel:.4f}, '
f'Zoom: {z_ratio:.1f}, '
f'RTOL: {rel_tol}, ATOL: {_OFFSET_ATOL}')
if not math.isclose(initial_offset, offset_hypot_rel,
rel_tol=rel_tol, abs_tol=_OFFSET_ATOL):
logging.warning('Offset check failed. %s', msg)
used_smooth_offset = True
if data.physical_id not in id_to_next_offset_and_zoom:
offset_success = False
logging.error('No physical camera is available to explain '
'offset changes!')
else:
next_initial_offset, next_initial_zoom = (
id_to_next_offset_and_zoom[data.physical_id]
)
next_offset_scaled_by_next_zoom = (
next_initial_offset / next_initial_zoom
)
absolutely_close = (
math.isclose(next_initial_offset, data.aruco_offset,
rel_tol=OFFSET_RTOL_SMOOTH_ZOOM,
abs_tol=OFFSET_ATOL_SMOOTH_ZOOM)
)
relatively_close = (
math.isclose(next_offset_scaled_by_next_zoom, offset_hypot_rel,
rel_tol=OFFSET_RTOL_SMOOTH_ZOOM,
abs_tol=OFFSET_ATOL_SMOOTH_ZOOM)
)
if not absolutely_close and not relatively_close:
offset_success = False
e_msg = ('Current offset did not match upcoming physical camera! '
f'{i} zoom: {data.result_zoom:.2f}, '
f'next initial offset: {next_initial_offset:.1f}, '
f'current offset: {data.aruco_offset:.1f}, '
f'current scaled offset: {offset_hypot_rel:.1f}, '
'next offset scaled according to next zoom: '
f'{next_offset_scaled_by_next_zoom:.1f}, '
f'RTOL: {OFFSET_RTOL_SMOOTH_ZOOM}, '
f'ATOL: {OFFSET_ATOL_SMOOTH_ZOOM}')
logging.error(e_msg)
else:
logging.debug('Successfully matched current offset with upcoming '
'physical camera offset')
if not logged_data:
logging.debug(msg)
if plot_name_stem:
plot_name = plot_name_stem.split('/')[-1].split('.')[0]
# Don't change print to logging. Used for KPI.
print(f'{plot_name}_max_rel_variation: ', max_rel_variation)
print(f'{plot_name}_max_rel_variation_zoom: ', max_rel_variation_zoom)
# Calculate RMS values
rms_z_variations = numpy.sqrt(numpy.mean(numpy.square(z_variations)))
rms_rel_variations = numpy.sqrt(numpy.mean(numpy.square(rel_variations)))
# Print RMS values
print(f'{plot_name}_rms_z_variations: ', rms_z_variations)
print(f'{plot_name}_rms_rel_variations: ', rms_rel_variations)
plot_variation(frame_numbers, z_variations, None,
f'{plot_name_stem}_variations.png', 'Zoom Variation')
plot_variation(frame_numbers, rel_variations, radius_tols,
f'{plot_name_stem}_relative.png', 'Relative Variation')
if offset_plot_name_stem:
plot_offset_trajectory(
[d.result_zoom for d in test_data],
offset_x_values,
offset_y_values,
hypots,
f'{offset_plot_name_stem}_offset_trajectory.gif' # GIF animation
)
return range_success and side_success and offset_success
def verify_preview_zoom_results(test_data, size, z_max, z_min, z_step_size,
plot_name_stem, number_of_cameras_to_test=0):
"""Verify that the output images' zoom level reflects the correct zoom ratios.
This test verifies that the center and radius of the circles in the output
images reflects the zoom ratios being set. The larger the zoom ratio, the
larger the circle. And the distance from the center of the circle to the
center of the image is proportional to the zoom ratio as well. Verifies
that circles are detected throughout the zoom range.
Args:
test_data: Iterable[ZoomTestData]
size: array; the width and height of the images
z_max: float; the maximum zoom ratio being tested
z_min: float; the minimum zoom ratio being tested
z_step_size: float; zoom step size to zoom from z_min to z_max
plot_name_stem: str; log path and name of the plot
number_of_cameras_to_test: [Optional][int]; minimum cameras in ZoomTestData
Returns:
Boolean whether the test passes (True) or not (False)
"""
test_success = True
test_data_zoom_values = [v.result_zoom for v in test_data]
results_z_max = max(test_data_zoom_values)
results_z_min = min(test_data_zoom_values)
logging.debug('Capture result: min zoom: %.2f vs max zoom: %.2f',
results_z_min, results_z_max)
# check if max zoom in capture result close to requested zoom range
if not (math.isclose(results_z_max, z_max, rel_tol=PRV_Z_RTOL) or
math.isclose(results_z_max, z_max - z_step_size, rel_tol=PRV_Z_RTOL)):
test_success = False
e_msg = (f'Max zoom ratio {results_z_max:.4f} in capture results '
f'is not close to requested zoom ratio {z_max:.2f} or '
f'close to max zoom ratio subtract zoom step '
f'{z_max - z_step_size:.2f} within {PRV_Z_RTOL:.2f}% tolerance.')
logging.error(e_msg)
else:
d_msg = (f'Max zoom ratio in capture results {results_z_max:.2f} is within'
f' tolerance of requested max zoom ratio {z_max:.2f}.')
logging.debug(d_msg)
if not math.isclose(results_z_min, z_min, rel_tol=PRV_Z_RTOL):
test_success = False
e_msg = (f'Min zoom ratio {results_z_min:.4f} in capture results is not '
f'close to requested min zoom {z_min:.2f} within {PRV_Z_RTOL:.2f}%'
f' tolerance.')
logging.error(e_msg)
else:
d_msg = (f'Min zoom ratio in capture results {results_z_min:.2f} is within'
f' tolerance of requested min zoom ratio {z_min:.2f}.')
logging.debug(d_msg)
return test_success and verify_zoom_data(
test_data, size, plot_name_stem=plot_name_stem,
monotonicity_atol=_PREVIEW_SMOOTH_ZOOM_OFFSET_MONOTONICITY_ATOL,
number_of_cameras_to_test=number_of_cameras_to_test)
def get_preview_zoom_params(zoom_range, steps):
"""Returns zoom min, max, step_size based on zoom range and steps.
Determine zoom min, max, step_size based on zoom range, steps.
Zoom max is capped due to current ITS box size limitation.
Args:
zoom_range: [float,float]; Camera's zoom range
steps: int; number of steps
Returns:
zoom_min: minimum zoom
zoom_max: maximum zoom
zoom_step_size: size of zoom steps
"""
# Determine test zoom range
logging.debug('z_range = %s', str(zoom_range))
zoom_min, zoom_max = float(zoom_range[0]), float(zoom_range[1])
zoom_max = min(zoom_max, ZOOM_MAX_THRESH * zoom_min)
zoom_step_size = (zoom_max-zoom_min) / (steps-1)
logging.debug('zoomRatioRange = %s z_min = %f z_max = %f z_stepSize = %f',
str(zoom_range), zoom_min, zoom_max, zoom_step_size)
return zoom_min, zoom_max, zoom_step_size
def plot_variation(frame_numbers, variations, tolerances, plot_name, ylabel):
"""Plots a variation against frame numbers with corresponding tolerances.
Args:
frame_numbers: List of frame numbers.
variations: List of variations.
tolerances: List of tolerances corresponding to each variation.
plot_name: Name for the plot file.
ylabel: Label for the y-axis.
"""
plt.figure(figsize=(40, 10))
plt.scatter(frame_numbers, variations, marker='o', linestyle='-',
color='blue', label=ylabel)
if tolerances:
plt.plot(frame_numbers, tolerances, linestyle='--', color='red',
label='Tolerance')
plt.xlabel('Frame Number', fontsize=12)
plt.ylabel(ylabel, fontsize=12)
plt.title(f'{ylabel} vs. Frame Number', fontsize=14)
plt.legend()
plt.grid(axis='y', linestyle='--')
plt.savefig(plot_name)
plt.close()
def plot_offset_trajectory(
zooms, x_offsets, y_offsets, hypots, plot_name):
"""Plot an animation describing offset drift for each zoom ratio.
Args:
zooms: Iterable[float]; zoom ratios corresponding to each offset.
x_offsets: Iterable[float]; x-axis offsets.
y_offsets: Iterable[float]; y-axis offsets.
hypots: Iterable[float]; offset hypotenuses (distances from image center).
plot_name: Plot name with path to save the plot.
"""
fig, (ax1, ax2) = plt.subplots(1, 2, constrained_layout=True)
fig.suptitle('Zoom Offset Trajectory')
scatter = ax1.scatter([], [], c='blue', marker='o')
line, = ax1.plot([], [], c='blue', linestyle='dashed')
# Preset axes limits, since data is added frame by frame (no initial data).
ax1.set_xlim(min(x_offsets), max(x_offsets), auto=True)
ax1.set_ylim(min(y_offsets), max(y_offsets), auto=True)
ax1.set_title('Offset (x, y) by Zoom Ratio')
ax1.set_xlabel('x')
ax1.set_ylabel('y')
# Function to animate each frame. Each frame corresponds to a capture/zoom.
def animate(i):
scatter.set_offsets((x_offsets[i], y_offsets[i]))
line.set_data(x_offsets[:i+1], y_offsets[:i+1])
ax1.set_title(f'Zoom: {zooms[i]:.3f}')
return scatter, line
ani = animation.FuncAnimation(
fig, animate, repeat=True, frames=len(hypots),
interval=_OFFSET_PLOT_INTERVAL
)
ax2.xaxis.set_major_locator(ticker.MultipleLocator(1)) # ticker every 1.0x.
ax2.plot(zooms, hypots, '-bo')
ax2.set_title('Offset Distance vs. Zoom Ratio')
ax2.set_xlabel('Zoom Ratio')
ax2.set_ylabel('Offset (pixels)')
writer = animation.PillowWriter(fps=_OFFSET_PLOT_FPS)
ani.save(plot_name, writer=writer)