# 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()
