1# Copyright 2020 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"""Verify zoom ratio scales circle sizes correctly.""" 15 16 17import logging 18import math 19import os.path 20from mobly import test_runner 21import numpy as np 22 23import cv2 24import its_base_test 25import camera_properties_utils 26import capture_request_utils 27import image_processing_utils 28import its_session_utils 29import opencv_processing_utils 30 31CIRCLE_COLOR = 0 # [0: black, 255: white] 32CIRCLE_TOL = 0.05 # contour area vs ideal circle area pi*((w+h)/4)**2 33LINE_COLOR = (255, 0, 0) # red 34LINE_THICKNESS = 5 35MIN_AREA_RATIO = 0.00015 # based on 2000/(4000x3000) pixels 36MIN_CIRCLE_PTS = 25 37MIN_FOCUS_DIST_TOL = 0.80 # allow charts a little closer than min 38NAME = os.path.splitext(os.path.basename(__file__))[0] 39NUM_STEPS = 10 40OFFSET_RTOL = 0.15 41RADIUS_RTOL = 0.10 42RADIUS_RTOL_MIN_FD = 0.15 43ZOOM_MAX_THRESH = 10.0 44ZOOM_MIN_THRESH = 2.0 45 46 47def get_test_tols_and_cap_size(cam, props, chart_distance, debug): 48 """Determine the tolerance per camera based on test rig and camera params. 49 50 Cameras are pre-filtered to only include supportable cameras. 51 Supportable cameras are: YUV(RGB) 52 53 Args: 54 cam: camera object 55 props: dict; physical camera properties dictionary 56 chart_distance: float; distance to chart in cm 57 debug: boolean; log additional data 58 59 Returns: 60 dict of TOLs with camera focal length as key 61 largest common size across all cameras 62 """ 63 ids = camera_properties_utils.logical_multi_camera_physical_ids(props) 64 physical_props = {} 65 physical_ids = [] 66 for i in ids: 67 physical_props[i] = cam.get_camera_properties_by_id(i) 68 # find YUV capable physical cameras 69 if camera_properties_utils.backward_compatible(physical_props[i]): 70 physical_ids.append(i) 71 72 # find physical camera focal lengths that work well with rig 73 chart_distance_m = abs(chart_distance)/100 # convert CM to M 74 test_tols = {} 75 test_yuv_sizes = [] 76 for i in physical_ids: 77 min_fd = physical_props[i]['android.lens.info.minimumFocusDistance'] 78 focal_l = physical_props[i]['android.lens.info.availableFocalLengths'][0] 79 logging.debug('cam[%s] min_fd: %.3f (diopters), fl: %.2f', 80 i, min_fd, focal_l) 81 yuv_sizes = capture_request_utils.get_available_output_sizes( 82 'yuv', physical_props[i]) 83 test_yuv_sizes.append(yuv_sizes) 84 if debug: 85 logging.debug('cam[%s] yuv sizes: %s', i, str(yuv_sizes)) 86 87 # determine if minimum focus distance is less than rig depth 88 if (math.isclose(min_fd, 0.0, rel_tol=1E-6) or # fixed focus 89 1.0/min_fd < chart_distance_m*MIN_FOCUS_DIST_TOL): 90 test_tols[focal_l] = RADIUS_RTOL 91 else: 92 test_tols[focal_l] = RADIUS_RTOL_MIN_FD 93 logging.debug('loosening RTOL for cam[%s]: ' 94 'min focus distance too large.', i) 95 # find intersection of formats for max common format 96 common_sizes = list(set.intersection(*[set(list) for list in test_yuv_sizes])) 97 if debug: 98 logging.debug('common_fmt: %s', max(common_sizes)) 99 100 return test_tols, max(common_sizes) 101 102 103def distance(x, y): 104 return math.sqrt(x**2 + y**2) 105 106 107def circle_cropped(circle, size): 108 """Determine if a circle is cropped by edge of img. 109 110 Args: 111 circle: list [x, y, radius] of circle 112 size: tuple (x, y) of size of img 113 114 Returns: 115 Boolean True if selected circle is cropped 116 """ 117 118 cropped = False 119 circle_x, circle_y = circle[0], circle[1] 120 circle_r = circle[2] 121 x_min, x_max = circle_x - circle_r, circle_x + circle_r 122 y_min, y_max = circle_y - circle_r, circle_y + circle_r 123 if x_min < 0 or y_min < 0 or x_max > size[0] or y_max > size[1]: 124 cropped = True 125 return cropped 126 127 128def find_center_circle(img, img_name, color, min_area, debug): 129 """Find the circle closest to the center of the image. 130 131 Finds all contours in the image. Rejects those too small and not enough 132 points to qualify as a circle. The remaining contours must have center 133 point of color=color and are sorted based on distance from the center 134 of the image. The contour closest to the center of the image is returned. 135 136 Note: hierarchy is not used as the hierarchy for black circles changes 137 as the zoom level changes. 138 139 Args: 140 img: numpy img array with pixel values in [0,255]. 141 img_name: str file name for saved image 142 color: int 0 --> black, 255 --> white 143 min_area: int minimum area of circles to screen out 144 debug: bool to save extra data 145 146 Returns: 147 circle: [center_x, center_y, radius] 148 """ 149 150 # gray scale & otsu threshold to binarize the image 151 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 152 _, img_bw = cv2.threshold( 153 np.uint8(gray), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) 154 155 # use OpenCV to find contours (connected components) 156 _, contours, _ = cv2.findContours(255 - img_bw, cv2.RETR_TREE, 157 cv2.CHAIN_APPROX_SIMPLE) 158 159 # check contours and find the best circle candidates 160 circles = [] 161 img_ctr = [gray.shape[1] // 2, gray.shape[0] // 2] 162 for contour in contours: 163 area = cv2.contourArea(contour) 164 if area > min_area and len(contour) >= MIN_CIRCLE_PTS: 165 shape = opencv_processing_utils.component_shape(contour) 166 radius = (shape['width'] + shape['height']) / 4 167 colour = img_bw[shape['cty']][shape['ctx']] 168 circlish = round((math.pi * radius**2) / area, 4) 169 if colour == color and (1 - CIRCLE_TOL <= circlish <= 1 + CIRCLE_TOL): 170 circles.append([shape['ctx'], shape['cty'], radius, circlish, area]) 171 172 if not circles: 173 raise AssertionError('No circle was detected. Please take pictures ' 174 'according to instructions carefully!') 175 176 if debug: 177 logging.debug('circles [x, y, r, pi*r**2/area, area]: %s', str(circles)) 178 179 # find circle closest to center 180 circles.sort(key=lambda x: distance(x[0] - img_ctr[0], x[1] - img_ctr[1])) 181 circle = circles[0] 182 183 # mark image center 184 size = gray.shape 185 m_x, m_y = size[1] // 2, size[0] // 2 186 marker_size = LINE_THICKNESS * 10 187 cv2.drawMarker(img, (m_x, m_y), LINE_COLOR, markerType=cv2.MARKER_CROSS, 188 markerSize=marker_size, thickness=LINE_THICKNESS) 189 190 # add circle to saved image 191 center_i = (int(round(circle[0], 0)), int(round(circle[1], 0))) 192 radius_i = int(round(circle[2], 0)) 193 cv2.circle(img, center_i, radius_i, LINE_COLOR, LINE_THICKNESS) 194 image_processing_utils.write_image(img / 255.0, img_name) 195 196 return [circle[0], circle[1], circle[2]] 197 198 199class ZoomTest(its_base_test.ItsBaseTest): 200 """Test the camera zoom behavior. 201 """ 202 203 def test_zoom(self): 204 test_data = {} 205 with its_session_utils.ItsSession( 206 device_id=self.dut.serial, 207 camera_id=self.camera_id, 208 hidden_physical_id=self.hidden_physical_id) as cam: 209 props = cam.get_camera_properties() 210 props = cam.override_with_hidden_physical_camera_props(props) 211 camera_properties_utils.skip_unless( 212 camera_properties_utils.zoom_ratio_range(props)) 213 214 # Load chart for scene 215 its_session_utils.load_scene( 216 cam, props, self.scene, self.tablet, self.chart_distance) 217 218 z_range = props['android.control.zoomRatioRange'] 219 logging.debug('testing zoomRatioRange: %s', str(z_range)) 220 debug = self.debug_mode 221 222 z_min, z_max = float(z_range[0]), float(z_range[1]) 223 camera_properties_utils.skip_unless(z_max >= z_min * ZOOM_MIN_THRESH) 224 z_list = np.arange(z_min, z_max, float(z_max - z_min) / (NUM_STEPS - 1)) 225 z_list = np.append(z_list, z_max) 226 227 # set TOLs based on camera and test rig params 228 if camera_properties_utils.logical_multi_camera(props): 229 test_tols, size = get_test_tols_and_cap_size( 230 cam, props, self.chart_distance, debug) 231 else: 232 fl = props['android.lens.info.availableFocalLengths'][0] 233 test_tols = {fl: RADIUS_RTOL} 234 yuv_size = capture_request_utils.get_largest_yuv_format(props) 235 size = [yuv_size['width'], yuv_size['height']] 236 logging.debug('capture size: %s', str(size)) 237 logging.debug('test TOLs: %s', str(test_tols)) 238 239 # do captures over zoom range and find circles with cv2 240 logging.debug('cv2_version: %s', cv2.__version__) 241 cam.do_3a() 242 req = capture_request_utils.auto_capture_request() 243 for i, z in enumerate(z_list): 244 logging.debug('zoom ratio: %.2f', z) 245 req['android.control.zoomRatio'] = z 246 cap = cam.do_capture( 247 req, {'format': 'yuv', 'width': size[0], 'height': size[1]}) 248 img = image_processing_utils.convert_capture_to_rgb_image( 249 cap, props=props) 250 img_name = '%s_%s.jpg' % (os.path.join(self.log_path, 251 NAME), round(z, 2)) 252 image_processing_utils.write_image(img, img_name) 253 254 # determine radius tolerance of capture 255 cap_fl = cap['metadata']['android.lens.focalLength'] 256 radius_tol = test_tols[cap_fl] 257 258 # convert to [0, 255] images with unsigned integer 259 img *= 255 260 img = img.astype(np.uint8) 261 262 # Find the center circle in img 263 circle = find_center_circle( 264 img, img_name, CIRCLE_COLOR, 265 min_area=MIN_AREA_RATIO * size[0] * size[1] * z * z, 266 debug=debug) 267 if circle_cropped(circle, size): 268 logging.debug('zoom %.2f is too large! Skip further captures', z) 269 break 270 test_data[i] = {'z': z, 'circle': circle, 'r_tol': radius_tol, 271 'fl': cap_fl} 272 273 # assert some range is tested before circles get too big 274 zoom_max_thresh = ZOOM_MAX_THRESH 275 if z_max < ZOOM_MAX_THRESH: 276 zoom_max_thresh = z_max 277 test_data_max_z = test_data[max(test_data.keys())]['z'] 278 logging.debug('zoom data max: %.2f', test_data_max_z) 279 if test_data_max_z < zoom_max_thresh: 280 raise AssertionError(f'Max zoom ratio tested: {test_data_max_z:.4f}, ' 281 f'range advertised min: {z_min}, max: {z_max} ' 282 f'THRESH: {zoom_max_thresh}') 283 284 # initialize relative size w/ zoom[0] for diff zoom ratio checks 285 radius_0 = float(test_data[0]['circle'][2]) 286 z_0 = float(test_data[0]['z']) 287 288 for i, data in test_data.items(): 289 logging.debug('Zoom: %.2f, fl: %.2f', data['z'], data['fl']) 290 offset_abs = [(data['circle'][0] - size[0] // 2), 291 (data['circle'][1] - size[1] // 2)] 292 logging.debug('Circle r: %.1f, center offset x, y: %d, %d', 293 data['circle'][2], offset_abs[0], offset_abs[1]) 294 z_ratio = data['z'] / z_0 295 296 # check relative size against zoom[0] 297 radius_ratio = data['circle'][2] / radius_0 298 logging.debug('r ratio req: %.3f, measured: %.3f', z_ratio, radius_ratio) 299 if not math.isclose(z_ratio, radius_ratio, rel_tol=data['r_tol']): 300 raise AssertionError(f'zoom: {z_ratio:.2f}, radius ratio: ' 301 f"{radius_ratio:.2f}, RTOL: {data['r_tol']}") 302 303 # check relative offset against init vals w/ no focal length change 304 if i == 0 or test_data[i-1]['fl'] != data['fl']: # set init values 305 z_init = float(data['z']) 306 offset_init = [data['circle'][0] - size[0]//2, 307 data['circle'][1] - size[1]//2] 308 else: # check 309 z_ratio = data['z'] / z_init 310 offset_rel = (distance(offset_abs[0], offset_abs[1]) / z_ratio / 311 distance(offset_init[0], offset_init[1])) 312 logging.debug('offset_rel: %.3f', offset_rel) 313 if not math.isclose(offset_rel, 1.0, rel_tol=OFFSET_RTOL): 314 raise AssertionError(f"zoom: {data['z']:.2f}, offset(rel): " 315 f'{offset_rel:.4f}, RTOL: {OFFSET_RTOL}') 316 317if __name__ == '__main__': 318 test_runner.main() 319