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