# Copyright 2023 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Verify 30FPS and 60FPS preview videos have the same FoV.""" import logging import math import os from mobly import test_runner import camera_properties_utils import image_fov_utils import image_processing_utils import its_base_test import its_session_utils import opencv_processing_utils import preview_processing_utils import video_processing_utils _ASPECT_RATIO_ATOL = 0.075 _HEIGHT = 'h' _FPS_ATOL = 0.5 _MAX_AREA = 1920 * 1440 # max mandatory preview stream resolution _MAX_CENTER_THRESHOLD_PERCENT = 0.075 _MIN_AREA = 640 * 480 # assume VGA to be min preview size _MIN_CENTER_THRESHOLD_PERCENT = 0.03 _RADIUS = 'r' _RADIUS_RTOL = 0.04 # 4 percent _RECORDING_DURATION = 2 # seconds _WIDTH = 'w' _X_OFFSET = 'x_offset' _Y_OFFSET = 'y_offset' def _calculate_center_offset_threshold(img_np_array): """Calculates appropriate center offset threshold. This function calculates a viable threshold that centers of two circles can be offset by for a given image size. The threshold percent is linearly interpolated between _MIN_CENTER_THRESHOLD_PERCENT and _MAX_CENTER_THRESHOLD_PERCENT according to the image size passed. Args: img_np_array: tuples; size of the image for which threshold has to be calculated. ex. (1080, 1920, 3) Returns: threshold value ratio between which the circle centers can differ """ img_area = img_np_array[0] * img_np_array[1] normalized_area = (img_area - _MIN_AREA) / (_MAX_AREA - _MIN_AREA) if normalized_area > 1 or normalized_area < 0: raise AssertionError('normalized area > 1 or < 0! ' f'image_area: {img_area}, ' f'normalized_area: {normalized_area}') # Threshold should be larger for images with smaller resolution normalized_threshold_percent = ( (1 - normalized_area) * (_MAX_CENTER_THRESHOLD_PERCENT - _MIN_CENTER_THRESHOLD_PERCENT)) return normalized_threshold_percent + _MIN_CENTER_THRESHOLD_PERCENT class ThirtySixtyFpsPreviewFoVMatchTest(its_base_test.ItsBaseTest): """Tests if preview FoV is within spec. The test captures two videos, one with 30 fps and another with 60 fps. A representative frame is selected from each video, and analyzed to ensure that the FoV changes in the two videos are within spec. Specifically, the test checks for the following parameters with and without preview stabilization: - The circle's aspect ratio remains constant - The center of the circle remains stable - The radius of circle remains constant """ def test_30_60fps_preview_fov_match(self): log_path = self.log_path with its_session_utils.ItsSession( device_id=self.dut.serial, camera_id=self.camera_id, hidden_physical_id=self.hidden_physical_id) as cam: props = cam.get_camera_properties() props = cam.override_with_hidden_physical_camera_props(props) def _do_preview_recording(cam, resolution, stabilize, fps): """Record a new set of data from the device. Captures camera preview frames. Args: cam: camera object resolution: str; preview resolution (ex. '1920x1080') stabilize: bool; True or False fps: integer; frames per second capture rate Returns: preview file name """ # Record stabilized and unstabilized previews preview_recording_obj = cam.do_preview_recording( resolution, _RECORDING_DURATION, stabilize=stabilize, ae_target_fps_min=fps, ae_target_fps_max=fps) logging.debug('Preview_recording_obj: %s', preview_recording_obj) logging.debug('Recorded output path for preview: %s', preview_recording_obj['recordedOutputPath']) # Grab and rename the preview recordings from the save location on DUT self.dut.adb.pull( [preview_recording_obj['recordedOutputPath'], log_path]) preview_file_name = ( preview_recording_obj['recordedOutputPath'].split('/')[-1]) logging.debug('recorded %s preview name: %s', fps, preview_file_name) # Validate preview frame rate preview_file_name_with_path = os.path.join( self.log_path, preview_file_name) preview_frame_rate = video_processing_utils.get_average_frame_rate( preview_file_name_with_path) if not math.isclose(preview_frame_rate, fps, abs_tol=_FPS_ATOL): logging.warning( 'Preview frame rate: %.1f, expected: %1.f, ATOL: %.2f', preview_frame_rate, fps, _FPS_ATOL) return preview_file_name # Load scene its_session_utils.load_scene(cam, props, self.scene, self.tablet, self.chart_distance) # Check skip condition first_api_level = its_session_utils.get_first_api_level(self.dut.serial) fps_ranges = camera_properties_utils.get_ae_target_fps_ranges(props) camera_properties_utils.skip_unless( [30, 30] and [60, 60] in fps_ranges and first_api_level >= its_session_utils.ANDROID15_API_LEVEL) # Log ffmpeg version being used video_processing_utils.log_ffmpeg_version() # Raise error if not FRONT or REAR facing camera camera_properties_utils.check_front_or_rear_camera(props) # List preview resolutions and find 720P or above to test supported_preview_sizes = cam.get_supported_preview_sizes(self.camera_id) preview_size = preview_processing_utils.get_720p_or_above_size( supported_preview_sizes) logging.debug('Testing preview resolution: %s', preview_size) # Recording preview streams 30/60 fps with stabilization off fps30_video = _do_preview_recording( cam, preview_size, stabilize=False, fps=30) fps60_video = _do_preview_recording( cam, preview_size, stabilize=False, fps=60) # Get last key frame from the 30/60 fps video with stabilization off fps30_frame = ( video_processing_utils.extract_last_key_frame_from_recording( log_path, fps30_video)) fps60_frame = ( video_processing_utils.extract_last_key_frame_from_recording( log_path, fps60_video)) # Compare 30/60 fps circles with stabilization off key_frame_name_stem = f'preview_{preview_size}_key_frame.png' fps30_key_frame_name = 'fps30_' + key_frame_name_stem fps30_circle = opencv_processing_utils.find_circle( fps30_frame, fps30_key_frame_name, image_fov_utils.CIRCLE_MIN_AREA, image_fov_utils.CIRCLE_COLOR) fps60_key_frame_name = 'fps60_' + key_frame_name_stem fps60_circle = opencv_processing_utils.find_circle( fps60_frame, fps60_key_frame_name, image_fov_utils.CIRCLE_MIN_AREA, image_fov_utils.CIRCLE_COLOR) # Ensure the circles have the same aspect ratio in 30/60 fps recordings fps30_aspect_ratio = ( fps30_circle[_WIDTH] / fps30_circle[_HEIGHT]) logging.debug('fps30 aspect ratio: %f', fps30_aspect_ratio) fps60_aspect_ratio = ( fps60_circle[_WIDTH] / fps60_circle[_HEIGHT]) logging.debug('fps60 aspect ratio: %f', fps60_aspect_ratio) # Identifying failure fail_msg = [] if not math.isclose(fps30_aspect_ratio, fps60_aspect_ratio, abs_tol=_ASPECT_RATIO_ATOL): fail_msg.append('Circle aspect_ratio changed too much: ' f'fps30 ratio: {fps30_aspect_ratio}, ' f'fps60 ratio: {fps60_aspect_ratio}, ' f'RTOL <= {_ASPECT_RATIO_ATOL}. ') # Distance between centers, x_offset and y_offset are relative to the # radius of the circle, so they're normalized. Not pixel values. fps30_center = ( fps30_circle[_X_OFFSET], fps30_circle[_Y_OFFSET]) logging.debug('fps30 center: %s', fps30_center) fps60_center = ( fps60_circle[_X_OFFSET], fps60_circle[_Y_OFFSET]) logging.debug('fps60 center: %s', fps60_center) center_offset = image_processing_utils.distance( fps30_center, fps60_center) img_np_array = fps30_frame.shape center_offset_threshold = ( _calculate_center_offset_threshold(img_np_array)) if center_offset > center_offset_threshold: fail_msg.append('Circle moved too much: fps30 center: ' f'{fps30_center}, ' f'fps60 center: {fps60_center}, ' f'expected distance < {center_offset_threshold}, ' f'actual_distance: {center_offset}. ') raise AssertionError(fail_msg) fps30_radius = fps30_circle[_RADIUS] fps60_radius = fps60_circle[_RADIUS] if not math.isclose( fps30_radius, fps60_radius, rel_tol=_RADIUS_RTOL): fail_msg.append('Too much FoV change: ' f'fps30 radius: {fps30_radius}, ' f'fps60 radius: {fps60_radius}, ' f'RTOL: {_RADIUS_RTOL}. ') raise AssertionError(fail_msg) if __name__ == '__main__': test_runner.main()