1# Copyright 2022 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"""Ensure that FoV reduction with Preview Stabilization is within spec."""
15
16import logging
17import math
18import os
19
20from mobly import test_runner
21
22import its_base_test
23import camera_properties_utils
24import image_fov_utils
25import image_processing_utils
26import its_session_utils
27import opencv_processing_utils
28import video_processing_utils
29
30_VIDEO_DURATION = 3  # seconds
31
32_MAX_STABILIZED_RADIUS_RATIO = 1.25  # An FOV reduction of 20% corresponds to an
33                                     # increase in lengths of 25%. So the
34                                     # stabilized circle's radius can be at most
35                                     # 1.25 times that of an unstabilized circle
36_MAX_STABILIZED_RADIUS_ATOL = 1  # 1 pixel tol for radii inaccuracy
37_ROUNDESS_DELTA_THRESHOLD = 0.05
38
39_MAX_CENTER_THRESHOLD_PERCENT = 0.075
40_MAX_AREA = 1920 * 1440  # max mandatory preview stream resolution
41_MIN_CENTER_THRESHOLD_PERCENT = 0.03
42_MIN_AREA = 176 * 144  # assume QCIF to be min preview size
43_KEY_FRAME_INDEX = -1  # last key frame
44_STABILIZED_SCALER_CROP_RTOL = 0.2  # 20% relative tolerance
45
46
47def _collect_data(cam, preview_size, stabilize):
48  """Capture a preview video from the device.
49
50  Captures camera preview frames from the passed device.
51
52  Args:
53    cam: camera object
54    preview_size: str; preview resolution. ex. '1920x1080'
55    stabilize: boolean; whether the preview should be stabilized or not
56
57  Returns:
58    recording object as described by cam.do_preview_recording
59  """
60
61  recording_obj = cam.do_preview_recording(preview_size, _VIDEO_DURATION,
62                                           stabilize)
63  logging.debug('Recorded output path: %s', recording_obj['recordedOutputPath'])
64  logging.debug('Tested quality: %s', recording_obj['quality'])
65
66  return recording_obj
67
68
69def _point_distance(p1_x, p1_y, p2_x, p2_y):
70  """Calculates the euclidean distance between two points.
71
72  Args:
73    p1_x: x coordinate of the first point
74    p1_y: y coordinate of the first point
75    p2_x: x coordinate of the second point
76    p2_y: y coordinate of the second point
77
78  Returns:
79    Euclidean distance between two points
80  """
81  return math.sqrt(pow(p1_x - p2_x, 2) + pow(p1_y - p2_y, 2))
82
83
84def _calculate_center_offset_threshold(image_size):
85  """Calculates appropriate center offset threshold.
86
87  This function calculates a viable threshold that centers of two circles can be
88  offset by for a given image size. The threshold percent is linearly
89  interpolated between _MIN_CENTER_THRESHOLD_PERCENT and
90  _MAX_CENTER_THRESHOLD_PERCENT according to the image size passed.
91
92  Args:
93    image_size: pair; size of the image for which threshold has to be
94                calculated. ex. (1920, 1080)
95
96  Returns:
97    threshold value ratio between which the circle centers can differ
98  """
99
100  img_area = image_size[0] * image_size[1]
101
102  normalized_area = (img_area - _MIN_AREA) / (_MAX_AREA - _MIN_AREA)
103
104  if normalized_area > 1 or normalized_area < 0:
105    raise AssertionError(f'normalized area > 1 or < 0! '
106                         f'image_size[0]: {image_size[0]}, '
107                         f'image_size[1]: {image_size[1]}, '
108                         f'normalized_area: {normalized_area}')
109
110  # Threshold should be larger for images with smaller resolution
111  normalized_threshold_percent = ((1 - normalized_area) *
112                                  (_MAX_CENTER_THRESHOLD_PERCENT -
113                                   _MIN_CENTER_THRESHOLD_PERCENT))
114
115  return normalized_threshold_percent + _MIN_CENTER_THRESHOLD_PERCENT
116
117
118class PreviewStabilizationFoVTest(its_base_test.ItsBaseTest):
119  """Tests if stabilized preview FoV is within spec.
120
121  The test captures two videos, one with preview stabilization on, and another
122  with preview stabilization off. A representative frame is selected from each
123  video, and analyzed to ensure that the FoV changes in the two videos are
124  within spec.
125
126  Specifically, the test checks for the following parameters with and without
127  preview stabilization:
128    - The circle roundness remains about constant
129    - The center of the circle remains relatively stable
130    - The size of circle changes no more that 20% i.e. the FOV changes at most
131      20%
132  """
133
134  def test_preview_stabilization_fov(self):
135    log_path = self.log_path
136
137    with its_session_utils.ItsSession(
138        device_id=self.dut.serial,
139        camera_id=self.camera_id,
140        hidden_physical_id=self.hidden_physical_id) as cam:
141
142      props = cam.get_camera_properties()
143      props = cam.override_with_hidden_physical_camera_props(props)
144
145      # Load scene.
146      its_session_utils.load_scene(cam, props, self.scene,
147                                   self.tablet, self.chart_distance)
148
149      # Check skip condition
150      first_api_level = its_session_utils.get_first_api_level(self.dut.serial)
151      camera_properties_utils.skip_unless(
152          first_api_level >= its_session_utils.ANDROID13_API_LEVEL,
153          'First API level should be {} or higher. Found {}.'.format(
154              its_session_utils.ANDROID13_API_LEVEL, first_api_level))
155
156      # Log ffmpeg version being used
157      video_processing_utils.log_ffmpeg_version()
158
159      supported_stabilization_modes = props[
160          'android.control.availableVideoStabilizationModes'
161      ]
162
163      camera_properties_utils.skip_unless(
164          supported_stabilization_modes is not None
165          and camera_properties_utils.STABILIZATION_MODE_PREVIEW
166          in supported_stabilization_modes,
167          'Preview Stabilization not supported',
168      )
169
170      # Raise error if not FRONT or REAR facing camera
171      camera_properties_utils.check_front_or_rear_camera(props)
172
173      # List of preview resolutions to test
174      supported_preview_sizes = cam.get_supported_preview_sizes(self.camera_id)
175      for size in video_processing_utils.LOW_RESOLUTION_SIZES:
176        if size in supported_preview_sizes:
177          supported_preview_sizes.remove(size)
178      logging.debug('Supported preview resolutions: %s',
179                    supported_preview_sizes)
180
181      test_failures = []
182
183      for preview_size in supported_preview_sizes:
184
185        # recording with stabilization off
186        ustab_rec_obj = _collect_data(cam, preview_size, False)
187        # recording with stabilization on
188        stab_rec_obj = _collect_data(cam, preview_size, True)
189
190        # Grab the unstabilized video from DUT
191        self.dut.adb.pull([ustab_rec_obj['recordedOutputPath'], log_path])
192        ustab_file_name = (ustab_rec_obj['recordedOutputPath'].split('/')[-1])
193        logging.debug('ustab_file_name: %s', ustab_file_name)
194
195        # Grab the stabilized video from DUT
196        self.dut.adb.pull([stab_rec_obj['recordedOutputPath'], log_path])
197        stab_file_name = (stab_rec_obj['recordedOutputPath'].split('/')[-1])
198        logging.debug('stab_file_name: %s', stab_file_name)
199
200        # Get all frames from the videos
201        ustab_file_list = video_processing_utils.extract_key_frames_from_video(
202            log_path, ustab_file_name)
203        logging.debug('Number of unstabilized iframes %d', len(ustab_file_list))
204
205        stab_file_list = video_processing_utils.extract_key_frames_from_video(
206            log_path, stab_file_name)
207        logging.debug('Number of stabilized iframes %d', len(stab_file_list))
208
209        # Extract last key frame to test from each video
210        ustab_frame = os.path.join(log_path,
211                                   video_processing_utils
212                                   .get_key_frame_to_process(ustab_file_list))
213        logging.debug('unstabilized frame: %s', ustab_frame)
214        stab_frame = os.path.join(log_path,
215                                  video_processing_utils
216                                  .get_key_frame_to_process(stab_file_list))
217        logging.debug('stabilized frame: %s', stab_frame)
218
219        # Convert to numpy matrix for analysis
220        ustab_np_image = image_processing_utils.convert_image_to_numpy_array(
221            ustab_frame)
222        logging.debug('unstabilized frame size: %s', ustab_np_image.shape)
223        stab_np_image = image_processing_utils.convert_image_to_numpy_array(
224            stab_frame)
225        logging.debug('stabilized frame size: %s', stab_np_image.shape)
226
227        image_size = stab_np_image.shape
228
229        # Get circles to compare
230        ustab_circle = opencv_processing_utils.find_circle(
231            ustab_np_image,
232            ustab_frame,
233            image_fov_utils.CIRCLE_MIN_AREA,
234            image_fov_utils.CIRCLE_COLOR)
235
236        stab_circle = opencv_processing_utils.find_circle(
237            stab_np_image,
238            stab_frame,
239            image_fov_utils.CIRCLE_MIN_AREA,
240            image_fov_utils.CIRCLE_COLOR)
241
242        failure_string = ''
243
244        # Ensure the circles are equally round w/ and w/o stabilization
245        ustab_roundness = ustab_circle['w'] / ustab_circle['h']
246        logging.debug('unstabilized roundness: %f', ustab_roundness)
247        stab_roundness = stab_circle['w'] / stab_circle['h']
248        logging.debug('stabilized roundness: %f', stab_roundness)
249
250        roundness_diff = abs(stab_roundness - ustab_roundness)
251        if roundness_diff > _ROUNDESS_DELTA_THRESHOLD:
252          failure_string += (f'Circle roundness changed too much: '
253                             f'unstabilized ratio: {ustab_roundness}, '
254                             f'stabilized ratio: {stab_roundness}, '
255                             f'Expected ratio difference <= '
256                             f'{_ROUNDESS_DELTA_THRESHOLD}, '
257                             f'actual ratio difference: {roundness_diff}. ')
258
259        # Distance between centers, x_offset and y_offset are relative to the
260        # radius of the circle, so they're normalized. Not pixel values.
261        unstab_center = (ustab_circle['x_offset'], ustab_circle['y_offset'])
262        logging.debug('unstabilized center: %s', unstab_center)
263        stab_center = (stab_circle['x_offset'], stab_circle['y_offset'])
264        logging.debug('stabilized center: %s', stab_center)
265
266        dist_centers = _point_distance(unstab_center[0], unstab_center[1],
267                                       stab_center[0], stab_center[1])
268        center_offset_threshold = _calculate_center_offset_threshold(image_size)
269        if dist_centers > center_offset_threshold:
270          failure_string += (f'Circle moved too much: '
271                             f'unstabilized center: ('
272                             f'{unstab_center[0]}, {unstab_center[1]}), '
273                             f'stabilized center: '
274                             f'({stab_center[0]}, {stab_center[1]}), '
275                             f'expected distance < {center_offset_threshold}, '
276                             f'actual_distance {dist_centers}. ')
277
278        # ensure radius of stabilized frame is within 120% of radius within
279        # unstabilized frame
280        ustab_radius = ustab_circle['r']
281        logging.debug('unstabilized radius: %f', ustab_radius)
282        stab_radius = stab_circle['r']
283        logging.debug('stabilized radius: %f', stab_radius)
284
285        max_stab_radius = (ustab_radius * _MAX_STABILIZED_RADIUS_RATIO +
286                           _MAX_STABILIZED_RADIUS_ATOL)
287        if stab_radius > max_stab_radius:
288          failure_string += (f'Too much FoV reduction: '
289                             f'unstabilized radius: {ustab_radius}, '
290                             f'stabilized radius: {stab_radius}, '
291                             f'expected max stabilized radius: '
292                             f'{max_stab_radius}. ')
293
294        # Calculate ratio of stabilized image's scaler crop region over
295        # active array size and compare it against the ratio of stabilized
296        # circle's radius over unstabilized circle
297        if stab_radius > ustab_radius:
298          stab_scaler_crop = (stab_rec_obj['captureMetadata']
299                              [_KEY_FRAME_INDEX]['android.scaler.cropRegion'])
300          scaler_crop_ratio = image_fov_utils.calc_scaler_crop_region_ratio(
301              stab_scaler_crop, props)
302          radius_ratio = ustab_radius / stab_radius
303          if math.isclose(scaler_crop_ratio, radius_ratio,
304                          rel_tol=_STABILIZED_SCALER_CROP_RTOL):
305            logging.debug('Crop region/active array: %f', scaler_crop_ratio)
306            logging.debug('Stabilized/unstabilized circle: %f', radius_ratio)
307            continue
308          else:
309            failure_string += (f'Too much FoV reduction: '
310                               f'Crop region: {stab_scaler_crop}, '
311                               f'Crop region ratio: {scaler_crop_ratio:.2%}, '
312                               f'Circle ratio: {radius_ratio:.2%}, '
313                               f'Tolerance: {_STABILIZED_SCALER_CROP_RTOL:.2%}')
314
315        if failure_string:
316          failure_string = f'{preview_size} fails FoV test. ' + failure_string
317          test_failures.append(failure_string)
318
319      if test_failures:
320        raise AssertionError(test_failures)
321
322
323if __name__ == '__main__':
324  test_runner.main()
325
326