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 17import logging 18import math 19 20import camera_properties_utils 21import capture_request_utils 22import image_processing_utils 23import numpy as np 24import opencv_processing_utils 25 26_CIRCLE_COLOR = 0 # [0: black, 255: white] 27_CIRCLE_AR_RTOL = 0.15 # contour width vs height (aspect ratio) 28_CIRCLISH_RTOL = 0.05 # contour area vs ideal circle area pi*((w+h)/4)**2 29_MIN_AREA_RATIO = 0.00013 # Found empirically with partners 30_MIN_CIRCLE_PTS = 25 31_MIN_FOCUS_DIST_TOL = 0.80 # allow charts a little closer than min 32_OFFSET_ATOL = 10 # number of pixels 33_OFFSET_RTOL_MIN_FD = 0.30 34_RADIUS_RTOL_MIN_FD = 0.15 35OFFSET_RTOL = 0.15 36RADIUS_RTOL = 0.10 37ZOOM_MAX_THRESH = 10.0 38 39 40def get_test_tols_and_cap_size(cam, props, chart_distance, debug): 41 """Determine the tolerance per camera based on test rig and camera params. 42 43 Cameras are pre-filtered to only include supportable cameras. 44 Supportable cameras are: YUV(RGB) 45 46 Args: 47 cam: camera object 48 props: dict; physical camera properties dictionary 49 chart_distance: float; distance to chart in cm 50 debug: boolean; log additional data 51 52 Returns: 53 dict of TOLs with camera focal length as key 54 largest common size across all cameras 55 """ 56 ids = camera_properties_utils.logical_multi_camera_physical_ids(props) 57 physical_props = {} 58 physical_ids = [] 59 for i in ids: 60 physical_props[i] = cam.get_camera_properties_by_id(i) 61 # find YUV capable physical cameras 62 if camera_properties_utils.backward_compatible(physical_props[i]): 63 physical_ids.append(i) 64 65 # find physical camera focal lengths that work well with rig 66 chart_distance_m = abs(chart_distance)/100 # convert CM to M 67 test_tols = {} 68 test_yuv_sizes = [] 69 for i in physical_ids: 70 yuv_sizes = capture_request_utils.get_available_output_sizes( 71 'yuv', physical_props[i]) 72 test_yuv_sizes.append(yuv_sizes) 73 if debug: 74 logging.debug('cam[%s] yuv sizes: %s', i, str(yuv_sizes)) 75 76 # determine if minimum focus distance is less than rig depth 77 min_fd = physical_props[i]['android.lens.info.minimumFocusDistance'] 78 for fl in physical_props[i]['android.lens.info.availableFocalLengths']: 79 logging.debug('cam[%s] min_fd: %.3f (diopters), fl: %.2f', i, min_fd, fl) 80 if (math.isclose(min_fd, 0.0, rel_tol=1E-6) or # fixed focus 81 (1.0/min_fd < chart_distance_m*_MIN_FOCUS_DIST_TOL)): 82 test_tols[fl] = (RADIUS_RTOL, OFFSET_RTOL) 83 else: 84 test_tols[fl] = (_RADIUS_RTOL_MIN_FD, _OFFSET_RTOL_MIN_FD) 85 logging.debug('loosening RTOL for cam[%s]: ' 86 'min focus distance too large.', i) 87 # find intersection of formats for max common format 88 common_sizes = list(set.intersection(*[set(list) for list in test_yuv_sizes])) 89 if debug: 90 logging.debug('common_fmt: %s', max(common_sizes)) 91 92 return test_tols, max(common_sizes) 93 94 95def get_center_circle(img, img_name, size, zoom_ratio, min_zoom_ratio, debug): 96 """Find circle closest to image center for scene with multiple circles. 97 98 If circle is not found due to zoom ratio being larger than ZOOM_MAX_THRESH 99 or the circle being cropped, None is returned. 100 101 Args: 102 img: numpy img array with pixel values in [0,255]. 103 img_name: str file name for saved image 104 size: width, height of the image 105 zoom_ratio: zoom_ratio for the particular capture 106 min_zoom_ratio: min_zoom_ratio supported by the camera device 107 debug: boolean to save extra data 108 109 Returns: 110 circle: [center_x, center_y, radius] if found, else None 111 """ 112 # Create a copy since convert_image_to_uint8 uses mutable np array methods 113 imgc = np.copy(img) 114 # convert [0, 1] image to [0, 255] and cast as uint8 115 imgc = image_processing_utils.convert_image_to_uint8(imgc) 116 117 # Find the center circle in img 118 try: 119 circle = opencv_processing_utils.find_center_circle( 120 imgc, img_name, _CIRCLE_COLOR, circle_ar_rtol=_CIRCLE_AR_RTOL, 121 circlish_rtol=_CIRCLISH_RTOL, 122 min_area=_MIN_AREA_RATIO * size[0] * size[1] * zoom_ratio * zoom_ratio, 123 min_circle_pts=_MIN_CIRCLE_PTS, debug=debug) 124 if opencv_processing_utils.is_circle_cropped(circle, size): 125 logging.debug('zoom %.2f is too large! Skip further captures', zoom_ratio) 126 return None 127 except AssertionError as e: 128 if zoom_ratio / min_zoom_ratio >= ZOOM_MAX_THRESH: 129 return None 130 else: 131 raise AssertionError( 132 'No circle detected for zoom ratio <= ' 133 f'{ZOOM_MAX_THRESH}. ' 134 'Take pictures according to instructions carefully!') from e 135 return circle 136 137 138def verify_zoom_results(test_data, size, z_max, z_min): 139 """Verify that the output images' zoom level reflects the correct zoom ratios. 140 141 This test verifies that the center and radius of the circles in the output 142 images reflects the zoom ratios being set. The larger the zoom ratio, the 143 larger the circle. And the distance from the center of the circle to the 144 center of the image is proportional to the zoom ratio as well. 145 146 Args: 147 test_data: dict; contains the detected circles for each zoom value 148 size: array; the width and height of the images 149 z_max: float; the maximum zoom ratio being tested 150 z_min: float; the minimum zoom ratio being tested 151 152 Returns: 153 Boolean whether the test passes (True) or not (False) 154 """ 155 # assert some range is tested before circles get too big 156 test_failed = False 157 zoom_max_thresh = ZOOM_MAX_THRESH 158 z_max_ratio = z_max / z_min 159 if z_max_ratio < ZOOM_MAX_THRESH: 160 zoom_max_thresh = z_max_ratio 161 test_data_max_z = (test_data[max(test_data.keys())]['z'] / 162 test_data[min(test_data.keys())]['z']) 163 logging.debug('test zoom ratio max: %.2f', test_data_max_z) 164 if test_data_max_z < zoom_max_thresh: 165 test_failed = True 166 e_msg = (f'Max zoom ratio tested: {test_data_max_z:.4f}, ' 167 f'range advertised min: {z_min}, max: {z_max} ' 168 f'THRESH: {zoom_max_thresh}') 169 logging.error(e_msg) 170 171 # initialize relative size w/ zoom[0] for diff zoom ratio checks 172 radius_0 = float(test_data[0]['circle'][2]) 173 z_0 = float(test_data[0]['z']) 174 175 for i, data in test_data.items(): 176 logging.debug('Zoom: %.2f, fl: %.2f', data['z'], data['fl']) 177 offset_xy = [(data['circle'][0] - size[0] // 2), 178 (data['circle'][1] - size[1] // 2)] 179 logging.debug('Circle r: %.1f, center offset x, y: %d, %d', 180 data['circle'][2], offset_xy[0], offset_xy[1]) 181 z_ratio = data['z'] / z_0 182 183 # check relative size against zoom[0] 184 radius_ratio = data['circle'][2] / radius_0 185 logging.debug('r ratio req: %.3f, measured: %.3f', 186 z_ratio, radius_ratio) 187 if not math.isclose(z_ratio, radius_ratio, rel_tol=data['r_tol']): 188 test_failed = True 189 e_msg = (f"Circle radius in capture taken at {z_0:.2f} " 190 "was expected to increase in capture taken at " 191 f"{data['z']:.2f} by {data['z']:.2f}/{z_0:.2f}=" 192 f"{z_ratio:.2f}, but it increased by " 193 f"{radius_ratio:.2f}. RTOL: {data['r_tol']}") 194 logging.error(e_msg) 195 196 # check relative offset against init vals w/ no focal length change 197 if i == 0 or test_data[i-1]['fl'] != data['fl']: # set init values 198 z_init = float(data['z']) 199 offset_hypot_init = math.hypot(offset_xy[0], offset_xy[1]) 200 logging.debug('offset_hypot_init: %.3f', offset_hypot_init) 201 else: # check 202 z_ratio = data['z'] / z_init 203 offset_hypot_rel = math.hypot(offset_xy[0], offset_xy[1]) / z_ratio 204 logging.debug('offset_hypot_rel: %.3f', offset_hypot_rel) 205 206 rel_tol = data['o_tol'] 207 if not math.isclose(offset_hypot_init, offset_hypot_rel, 208 rel_tol=rel_tol, abs_tol=_OFFSET_ATOL): 209 test_failed = True 210 e_msg = (f"zoom: {data['z']:.2f}, " 211 f'offset init: {offset_hypot_init:.4f}, ' 212 f'offset rel: {offset_hypot_rel:.4f}, ' 213 f'RTOL: {rel_tol}, ATOL: {_OFFSET_ATOL}') 214 logging.error(e_msg) 215 216 return not test_failed 217 218