summaryrefslogtreecommitdiff
path: root/apps/CameraITS/utils/zoom_capture_utils.py
blob: 2e862b4ad7a9ef671e6f9e550f068450182cb119 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# 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.
"""

import logging
import math

import camera_properties_utils
import capture_request_utils
import image_processing_utils
import numpy as np
import opencv_processing_utils

_CIRCLE_COLOR = 0  # [0: black, 255: white]
_CIRCLE_AR_RTOL = 0.15  # contour width vs height (aspect ratio)
_CIRCLISH_RTOL = 0.05  # contour area vs ideal circle area pi*((w+h)/4)**2
_MIN_AREA_RATIO = 0.00015  # based on 2000/(4000x3000) pixels
_MIN_CIRCLE_PTS = 25
_MIN_FOCUS_DIST_TOL = 0.80  # allow charts a little closer than min
_OFFSET_ATOL = 10  # number of pixels
_OFFSET_RTOL_MIN_FD = 0.30
_RADIUS_RTOL_MIN_FD = 0.15
OFFSET_RTOL = 0.15
RADIUS_RTOL = 0.10
ZOOM_MAX_THRESH = 10.0


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 get_center_circle(img, img_name, size, zoom_ratio, min_zoom_ratio, debug):
  """Find circle closest to image center for scene with multiple circles.

  If circle is not found due to zoom ratio being larger than ZOOM_MAX_THRESH
  or the circle being cropped, None is returned.

  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
    debug: boolean to save extra data

  Returns:
    circle: [center_x, center_y, radius] if found, else None
  """
  # Create a copy since convert_image_to_uint8 uses mutable np array methods
  imgc = np.copy(img)
  # convert [0, 1] image to [0, 255] and cast as uint8
  imgc = image_processing_utils.convert_image_to_uint8(imgc)

  # Find the center circle in img
  try:
    circle = opencv_processing_utils.find_center_circle(
        imgc, img_name, _CIRCLE_COLOR, circle_ar_rtol=_CIRCLE_AR_RTOL,
        circlish_rtol=_CIRCLISH_RTOL,
        min_area=_MIN_AREA_RATIO * size[0] * size[1] * zoom_ratio * zoom_ratio,
        min_circle_pts=_MIN_CIRCLE_PTS, debug=debug)
    if opencv_processing_utils.is_circle_cropped(circle, size):
      logging.debug('zoom %.2f is too large! Skip further captures', zoom_ratio)
      return None
  except AssertionError as e:
    if zoom_ratio / min_zoom_ratio >= ZOOM_MAX_THRESH:
      return None
    else:
      raise AssertionError(
          'No circle detected for zoom ratio <= '
          f'{ZOOM_MAX_THRESH}. '
          'Take pictures according to instructions carefully!') from e
  return circle


def verify_zoom_results(test_data, size, z_max, z_min):
  """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: dict; contains the detected circles for each zoom value
    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

  Returns:
    Boolean whether the test passes (True) or not (False)
  """
  # assert some range is tested before circles get too big
  test_failed = False
  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
  test_data_max_z = (test_data[max(test_data.keys())]['z'] /
                     test_data[min(test_data.keys())]['z'])
  logging.debug('test zoom ratio max: %.2f', test_data_max_z)
  if test_data_max_z < zoom_max_thresh:
    test_failed = True
    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}')
    logging.error(e_msg)

  # initialize relative size w/ zoom[0] for diff zoom ratio checks
  radius_0 = float(test_data[0]['circle'][2])
  z_0 = float(test_data[0]['z'])

  for i, data in test_data.items():
    logging.debug('Zoom: %.2f, fl: %.2f', data['z'], data['fl'])
    offset_xy = [(data['circle'][0] - size[0] // 2),
                 (data['circle'][1] - size[1] // 2)]
    logging.debug('Circle r: %.1f, center offset x, y: %d, %d',
                  data['circle'][2], offset_xy[0], offset_xy[1])
    z_ratio = data['z'] / z_0

    # check relative size against zoom[0]
    radius_ratio = data['circle'][2] / radius_0
    logging.debug('r ratio req: %.3f, measured: %.3f',
                  z_ratio, radius_ratio)
    if not math.isclose(z_ratio, radius_ratio, rel_tol=data['r_tol']):
      test_failed = True
      e_msg = (f"Circle radius in capture taken at {z_0:.2f} "
               "was expected to increase in capture taken at "
               f"{data['z']:.2f} by {data['z']:.2f}/{z_0:.2f}="
               f"{z_ratio:.2f}, but it increased by "
               f"{radius_ratio:.2f}. RTOL: {data['r_tol']}")
      logging.error(e_msg)

    # check relative offset against init vals w/ no focal length change
    if i == 0 or test_data[i-1]['fl'] != data['fl']:  # set init values
      z_init = float(data['z'])
      offset_hypot_init = math.hypot(offset_xy[0], offset_xy[1])
      logging.debug('offset_hypot_init: %.3f', offset_hypot_init)
    else:  # check
      z_ratio = data['z'] / z_init
      offset_hypot_rel = math.hypot(offset_xy[0], offset_xy[1]) / z_ratio
      logging.debug('offset_hypot_rel: %.3f', offset_hypot_rel)

      rel_tol = data['o_tol']
      if not math.isclose(offset_hypot_init, offset_hypot_rel,
                          rel_tol=rel_tol, abs_tol=_OFFSET_ATOL):
        test_failed = True
        e_msg = (f"zoom: {data['z']:.2f}, "
                 f'offset init: {offset_hypot_init:.4f}, '
                 f'offset rel: {offset_hypot_rel:.4f}, '
                 f'RTOL: {rel_tol}, ATOL: {_OFFSET_ATOL}')
        logging.error(e_msg)

  return not test_failed