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 15import math 16import os.path 17import cv2 18 19import its.caps 20import its.cv2image 21import its.device 22import its.image 23import its.objects 24 25import numpy as np 26 27CIRCLE_COLOR = 0 # [0: black, 255: white] 28CIRCLE_TOL = 0.05 # contour area vs ideal circle area pi*((w+h)/4)**2 29LINE_COLOR = (255, 0, 0) # red 30LINE_THICKNESS = 5 31MIN_AREA_RATIO = 0.00015 # based on 2000/(4000x3000) pixels 32MIN_CIRCLE_PTS = 25 33NAME = os.path.basename(__file__).split('.')[0] 34NUM_STEPS = 10 35OFFSET_RTOL = 0.10 36RADIUS_RTOL = 0.10 37ZOOM_MAX_THRESH = 10.0 38ZOOM_MIN_THRESH = 2.0 39 40 41def distance((x, y)): 42 return math.sqrt(x**2 + y**2) 43 44 45def circle_cropped(circle, size): 46 """Determine if a circle is cropped by edge of img. 47 48 Args: 49 circle: list; [x, y, radius] of circle 50 size: tuple; [x, y] size of img 51 52 Returns: 53 Boolean True if selected circle is cropped 54 """ 55 56 cropped = False 57 circle_x, circle_y = circle[0], circle[1] 58 circle_r = circle[2] 59 x_min, x_max = circle_x - circle_r, circle_x + circle_r 60 y_min, y_max = circle_y - circle_r, circle_y + circle_r 61 if x_min < 0 or y_min < 0 or x_max > size[0] or y_max > size[1]: 62 cropped = True 63 return cropped 64 65 66def find_center_circle(img, name, color, min_area, debug): 67 """Find the circle closest to the center of the image. 68 69 Finds all contours in the image. Rejects those too small and not enough 70 points to qualify as a circle. The remaining contours must have center 71 point of color=color and are sorted based on distance from the center 72 of the image. The contour closest to the center of the image is returned. 73 74 Note: hierarchy is not used as the hierarchy for black circles changes 75 as the zoom level changes. 76 77 Args: 78 img: numpy img array with pixel values in [0,255]. 79 name: str; file name 80 color: int; 0: black, 255: white 81 min_area: int; minimum area of circles to screen out 82 debug: bool; save extra data 83 84 Returns: 85 circle: [center_x, center_y, radius] 86 """ 87 88 # gray scale & otsu threshold to binarize the image 89 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 90 _, img_bw = cv2.threshold(np.uint8(gray), 0, 255, 91 cv2.THRESH_BINARY + cv2.THRESH_OTSU) 92 93 # use OpenCV to find contours (connected components) 94 cv2_version = cv2.__version__ 95 if cv2_version.startswith('3.'): # OpenCV 3.x 96 _, contours, _ = cv2.findContours( 97 255-img_bw, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) 98 else: # OpenCV 2.x and 4.x 99 contours, _ = cv2.findContours( 100 255-img_bw, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) 101 102 # check contours and find the best circle candidates 103 circles = [] 104 img_ctr = [gray.shape[1]/2, gray.shape[0]/2] 105 for contour in contours: 106 area = cv2.contourArea(contour) 107 if area > min_area and len(contour) >= MIN_CIRCLE_PTS: 108 shape = its.cv2image.component_shape(contour) 109 radius = (shape['width'] + shape['height']) / 4 110 colour = img_bw[shape['cty']][shape['ctx']] 111 circlish = round((math.pi * radius**2) / area, 4) 112 if colour == color and (1-CIRCLE_TOL <= circlish <= 1+CIRCLE_TOL): 113 circles.append([shape['ctx'], shape['cty'], radius, circlish, 114 area]) 115 116 if debug: 117 circles.sort(key=lambda x: abs(x[3]-1.0)) # sort for best circles 118 print 'circles [x, y, r, pi*r**2/area, area]:', circles 119 120 # find circle closest to center 121 circles.sort(key=lambda x: distance((x[0]-img_ctr[0], x[1]-img_ctr[1]))) 122 circle = circles[0] 123 124 # mark image center 125 size = gray.shape 126 m_x, m_y = size[1]/2, size[0]/2 127 marker_size = LINE_THICKNESS * 10 128 if cv2_version.startswith('2.4.'): 129 cv2.line(img, (m_x-marker_size/2, m_y), (m_x+marker_size/2, m_y), 130 LINE_COLOR, LINE_THICKNESS) 131 cv2.line(img, (m_x, m_y-marker_size/2), (m_x, m_y+marker_size/2), 132 LINE_COLOR, LINE_THICKNESS) 133 elif cv2_version.startswith('3.2.'): 134 cv2.drawMarker(img, (m_x, m_y), LINE_COLOR, 135 markerType=cv2.MARKER_CROSS, 136 markerSize=marker_size, 137 thickness=LINE_THICKNESS) 138 139 # add circle to saved image 140 center_i = (int(round(circle[0], 0)), int(round(circle[1], 0))) 141 radius_i = int(round(circle[2], 0)) 142 cv2.circle(img, center_i, radius_i, LINE_COLOR, LINE_THICKNESS) 143 its.image.write_image(img/255.0, name) 144 145 if not circles: 146 print 'No circle was detected. Please take pictures according', 147 print 'to instruction carefully!\n' 148 assert False 149 150 return [circle[0], circle[1], circle[2]] 151 152 153def main(): 154 """Test the camera zoom behavior.""" 155 156 z_test_list = [] 157 fls = [] 158 circles = [] 159 with its.device.ItsSession() as cam: 160 props = cam.get_camera_properties() 161 its.caps.skip_unless(its.caps.zoom_ratio_range(props)) 162 163 z_range = props['android.control.zoomRatioRange'] 164 print 'testing zoomRatioRange:', z_range 165 yuv_size = its.objects.get_largest_yuv_format(props) 166 size = [yuv_size['width'], yuv_size['height']] 167 debug = its.caps.debug_mode() 168 169 z_min, z_max = float(z_range[0]), float(z_range[1]) 170 its.caps.skip_unless(z_max >= z_min*ZOOM_MIN_THRESH) 171 z_list = np.arange(z_min, z_max, float(z_max-z_min)/(NUM_STEPS-1)) 172 z_list = np.append(z_list, z_max) 173 174 # do captures over zoom range 175 req = its.objects.auto_capture_request() 176 for i, z in enumerate(z_list): 177 print 'zoom ratio: %.2f' % z 178 req['android.control.zoomRatio'] = z 179 cap = cam.do_capture(req, cam.CAP_YUV) 180 img = its.image.convert_capture_to_rgb_image(cap, props=props) 181 182 # convert to [0, 255] images with unsigned integer 183 img *= 255 184 img = img.astype(np.uint8) 185 186 # Find the circles in img 187 circle = find_center_circle( 188 img, '%s_%s.jpg' % (NAME, round(z, 2)), CIRCLE_COLOR, 189 min_area=MIN_AREA_RATIO*size[0]*size[1]*z*z, debug=debug) 190 if circle_cropped(circle, size): 191 print 'zoom %.2f is too large! Skip further captures' % z 192 break 193 circles.append(circle) 194 z_test_list.append(z) 195 fls.append(cap['metadata']['android.lens.focalLength']) 196 197 # assert some range is tested before circles get too big 198 zoom_max_thresh = ZOOM_MAX_THRESH 199 if z_max < ZOOM_MAX_THRESH: 200 zoom_max_thresh = z_max 201 msg = 'Max zoom level tested: %d, THRESH: %d' % ( 202 z_test_list[-1], zoom_max_thresh) 203 assert z_test_list[-1] >= zoom_max_thresh, msg 204 205 # initialize relative size w/ zoom[0] for diff zoom ratio checks 206 radius_0 = float(circles[0][2]) 207 z_0 = float(z_test_list[0]) 208 209 for i, z in enumerate(z_test_list): 210 print '\nZoom: %.2f, fl: %.2f' % (z, fls[i]) 211 offset_abs = ((circles[i][0] - size[0]/2), (circles[i][1] - size[1]/2)) 212 print 'Circle r: %.1f, center offset x, y: %d, %d' % ( 213 circles[i][2], offset_abs[0], offset_abs[1]) 214 z_ratio = z / z_0 215 216 # check relative size against zoom[0] 217 radius_ratio = circles[i][2]/radius_0 218 print 'radius_ratio: %.3f' % radius_ratio 219 msg = 'zoom: %.2f, radius ratio: %.2f, RTOL: %.2f' % ( 220 z_ratio, radius_ratio, RADIUS_RTOL) 221 assert np.isclose(z_ratio, radius_ratio, rtol=RADIUS_RTOL), msg 222 223 # check relative offset against init vals w/ no focal length change 224 if i == 0 or fls[i-1] != fls[i]: # set init values 225 z_init = float(z_test_list[i]) 226 offset_init = (circles[i][0] - size[0] / 2, 227 circles[i][1] - size[1] / 2) 228 else: # check 229 z_ratio = z / z_init 230 offset_rel = (distance(offset_abs) / z_ratio / 231 distance(offset_init)) 232 print 'offset_rel: %.3f' % offset_rel 233 msg = 'zoom: %.2f, offset(rel): %.2f, RTOL: %.2f' % ( 234 z, offset_rel, OFFSET_RTOL) 235 assert np.isclose(offset_rel, 1.0, rtol=OFFSET_RTOL), msg 236 237 238if __name__ == '__main__': 239 main() 240