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