1# Copyright 2023 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 30FPS and 60FPS preview videos have the same FoV."""
15
16
17import logging
18import math
19import os
20
21from mobly import test_runner
22
23import camera_properties_utils
24import image_fov_utils
25import image_processing_utils
26import its_base_test
27import its_session_utils
28import opencv_processing_utils
29import preview_processing_utils
30import video_processing_utils
31
32_ASPECT_RATIO_ATOL = 0.075
33_HEIGHT = 'h'
34_FPS_ATOL = 0.5
35
36_MAX_AREA = 1920 * 1440  # max mandatory preview stream resolution
37_MAX_CENTER_THRESHOLD_PERCENT = 0.075
38_MIN_AREA = 640 * 480  # assume VGA to be min preview size
39_MIN_CENTER_THRESHOLD_PERCENT = 0.03
40
41_RADIUS = 'r'
42_RADIUS_RTOL = 0.04  # 4 percent
43
44_RECORDING_DURATION = 2  # seconds
45_WIDTH = 'w'
46_X_OFFSET = 'x_offset'
47_Y_OFFSET = 'y_offset'
48
49
50def _calculate_center_offset_threshold(img_np_array):
51  """Calculates appropriate center offset threshold.
52
53  This function calculates a viable threshold that centers of two circles can be
54  offset by for a given image size. The threshold percent is linearly
55  interpolated between _MIN_CENTER_THRESHOLD_PERCENT and
56  _MAX_CENTER_THRESHOLD_PERCENT according to the image size passed.
57
58  Args:
59    img_np_array: tuples; size of the image for which threshold has to be
60                calculated. ex. (1080, 1920, 3)
61
62  Returns:
63    threshold value ratio between which the circle centers can differ
64  """
65
66  img_area = img_np_array[0] * img_np_array[1]
67
68  normalized_area = (img_area - _MIN_AREA) / (_MAX_AREA - _MIN_AREA)
69
70  if normalized_area > 1 or normalized_area < 0:
71    raise AssertionError('normalized area > 1 or < 0! '
72                         f'image_area: {img_area}, '
73                         f'normalized_area: {normalized_area}')
74
75  # Threshold should be larger for images with smaller resolution
76  normalized_threshold_percent = (
77      (1 - normalized_area) * (_MAX_CENTER_THRESHOLD_PERCENT -
78                               _MIN_CENTER_THRESHOLD_PERCENT))
79
80  return normalized_threshold_percent + _MIN_CENTER_THRESHOLD_PERCENT
81
82
83class ThirtySixtyFpsPreviewFoVMatchTest(its_base_test.ItsBaseTest):
84  """Tests if preview FoV is within spec.
85
86  The test captures two videos, one with 30 fps and another with 60 fps.
87  A representative frame is selected from each video, and analyzed to
88  ensure that the FoV changes in the two videos are within spec.
89
90  Specifically, the test checks for the following parameters with and without
91  preview stabilization:
92    - The circle's aspect ratio remains constant
93    - The center of the circle remains stable
94    - The radius of circle remains constant
95  """
96
97  def test_30_60fps_preview_fov_match(self):
98    log_path = self.log_path
99
100    with its_session_utils.ItsSession(
101        device_id=self.dut.serial,
102        camera_id=self.camera_id,
103        hidden_physical_id=self.hidden_physical_id) as cam:
104
105      props = cam.get_camera_properties()
106      props = cam.override_with_hidden_physical_camera_props(props)
107
108      def _do_preview_recording(cam, resolution, stabilize, fps):
109        """Record a new set of data from the device.
110
111        Captures camera preview frames.
112
113        Args:
114          cam: camera object
115          resolution: str; preview resolution (ex. '1920x1080')
116          stabilize: bool; True or False
117          fps: integer; frames per second capture rate
118
119        Returns:
120          preview file name
121        """
122
123        # Record stabilized and unstabilized previews
124        preview_recording_obj = cam.do_preview_recording(
125            resolution, _RECORDING_DURATION, stabilize=stabilize,
126            ae_target_fps_min=fps, ae_target_fps_max=fps)
127        logging.debug('Preview_recording_obj: %s', preview_recording_obj)
128        logging.debug('Recorded output path for preview: %s',
129                      preview_recording_obj['recordedOutputPath'])
130
131        # Grab and rename the preview recordings from the save location on DUT
132        self.dut.adb.pull(
133            [preview_recording_obj['recordedOutputPath'], log_path])
134        preview_file_name = (
135            preview_recording_obj['recordedOutputPath'].split('/')[-1])
136        logging.debug('recorded %s preview name: %s', fps, preview_file_name)
137
138        # Validate preview frame rate
139        preview_file_name_with_path = os.path.join(
140            self.log_path, preview_file_name)
141        preview_frame_rate = video_processing_utils.get_average_frame_rate(
142            preview_file_name_with_path)
143        if not math.isclose(preview_frame_rate, fps, abs_tol=_FPS_ATOL):
144          logging.warning(
145              'Preview frame rate: %.1f, expected: %1.f, ATOL: %.2f',
146              preview_frame_rate, fps, _FPS_ATOL)
147
148        return preview_file_name
149
150      # Load scene
151      its_session_utils.load_scene(cam, props, self.scene,
152                                   self.tablet, self.chart_distance)
153
154      # Check skip condition
155      first_api_level = its_session_utils.get_first_api_level(self.dut.serial)
156      fps_ranges = camera_properties_utils.get_ae_target_fps_ranges(props)
157      camera_properties_utils.skip_unless(
158          [30, 30] and [60, 60] in fps_ranges and
159          first_api_level >= its_session_utils.ANDROID15_API_LEVEL)
160
161      # Log ffmpeg version being used
162      video_processing_utils.log_ffmpeg_version()
163
164      # Raise error if not FRONT or REAR facing camera
165      camera_properties_utils.check_front_or_rear_camera(props)
166
167      # List preview resolutions and find 720P or above to test
168      supported_preview_sizes = cam.get_supported_preview_sizes(self.camera_id)
169      preview_size = preview_processing_utils.get_720p_or_above_size(
170          supported_preview_sizes)
171      logging.debug('Testing preview resolution: %s', preview_size)
172
173      # Recording preview streams 30/60 fps with stabilization off
174      fps30_video = _do_preview_recording(
175          cam, preview_size, stabilize=False, fps=30)
176      fps60_video = _do_preview_recording(
177          cam, preview_size, stabilize=False, fps=60)
178
179      # Get last key frame from the 30/60 fps video with stabilization off
180      fps30_frame = (
181          video_processing_utils.extract_last_key_frame_from_recording(
182              log_path, fps30_video))
183      fps60_frame = (
184          video_processing_utils.extract_last_key_frame_from_recording(
185              log_path, fps60_video))
186
187      # Compare 30/60 fps circles with stabilization off
188      key_frame_name_stem = f'preview_{preview_size}_key_frame.png'
189      fps30_key_frame_name = 'fps30_' + key_frame_name_stem
190      fps30_circle = opencv_processing_utils.find_circle(
191          fps30_frame, fps30_key_frame_name,
192          image_fov_utils.CIRCLE_MIN_AREA, image_fov_utils.CIRCLE_COLOR)
193      fps60_key_frame_name = 'fps60_' + key_frame_name_stem
194      fps60_circle = opencv_processing_utils.find_circle(
195          fps60_frame, fps60_key_frame_name,
196          image_fov_utils.CIRCLE_MIN_AREA, image_fov_utils.CIRCLE_COLOR)
197
198      # Ensure the circles have the same aspect ratio in 30/60 fps recordings
199      fps30_aspect_ratio = (
200          fps30_circle[_WIDTH] / fps30_circle[_HEIGHT])
201      logging.debug('fps30 aspect ratio: %f', fps30_aspect_ratio)
202      fps60_aspect_ratio = (
203          fps60_circle[_WIDTH] / fps60_circle[_HEIGHT])
204      logging.debug('fps60 aspect ratio: %f', fps60_aspect_ratio)
205
206      # Identifying failure
207      fail_msg = []
208      if not math.isclose(fps30_aspect_ratio, fps60_aspect_ratio,
209                          abs_tol=_ASPECT_RATIO_ATOL):
210        fail_msg.append('Circle aspect_ratio changed too much: '
211                        f'fps30 ratio: {fps30_aspect_ratio}, '
212                        f'fps60 ratio: {fps60_aspect_ratio}, '
213                        f'RTOL <= {_ASPECT_RATIO_ATOL}. ')
214
215      # Distance between centers, x_offset and y_offset are relative to the
216      # radius of the circle, so they're normalized. Not pixel values.
217      fps30_center = (
218          fps30_circle[_X_OFFSET], fps30_circle[_Y_OFFSET])
219      logging.debug('fps30 center: %s', fps30_center)
220      fps60_center = (
221          fps60_circle[_X_OFFSET], fps60_circle[_Y_OFFSET])
222      logging.debug('fps60 center: %s', fps60_center)
223
224      center_offset = image_processing_utils.distance(
225          fps30_center, fps60_center)
226      img_np_array = fps30_frame.shape
227      center_offset_threshold = (
228          _calculate_center_offset_threshold(img_np_array))
229      if center_offset > center_offset_threshold:
230        fail_msg.append('Circle moved too much: fps30 center: '
231                        f'{fps30_center}, '
232                        f'fps60 center: {fps60_center}, '
233                        f'expected distance < {center_offset_threshold}, '
234                        f'actual_distance: {center_offset}. ')
235
236        raise AssertionError(fail_msg)
237      fps30_radius = fps30_circle[_RADIUS]
238      fps60_radius = fps60_circle[_RADIUS]
239      if not math.isclose(
240          fps30_radius, fps60_radius, rel_tol=_RADIUS_RTOL):
241        fail_msg.append('Too much FoV change: '
242                        f'fps30 radius: {fps30_radius}, '
243                        f'fps60 radius: {fps60_radius}, '
244                        f'RTOL: {_RADIUS_RTOL}. ')
245        raise AssertionError(fail_msg)
246
247if __name__ == '__main__':
248  test_runner.main()
249