• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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