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