blob: 7bdf205c3d5fad0d07299418c09a7558a2d48eca [file] [log] [blame]
# 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 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)
logging.debug('Supported preview resolutions: %s',
supported_preview_sizes)
preview_size = video_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()