1# Copyright 2023 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"""Verify preview matches video output during video zoom."""
15
16import logging
17import math
18import os
19
20from mobly import test_runner
21
22import its_base_test
23import camera_properties_utils
24import capture_request_utils
25import image_processing_utils
26import its_session_utils
27import preview_processing_utils
28import video_processing_utils
29import zoom_capture_utils
30
31_CIRCLE_R = 2
32_CIRCLE_X = 0
33_CIRCLE_Y = 1
34_CIRCLISH_RTOL = 0.1  # contour area vs ideal circle area pi*((w+h)/4)**2
35_MAX_STR = 'max'
36_MIN_STR = 'min'
37_MIN_AREA_RATIO = 0.00015  # based on 2000/(4000x3000) pixels
38_MIN_CIRCLE_PTS = 10
39_MIN_RESOLUTION_AREA = 1280*720  # 720P
40_MIN_ZOOM_SCALE_CHART = 0.70  # zoom factor to trigger scaled chart
41_NAME = os.path.splitext(os.path.basename(__file__))[0]
42_OFFSET_TOL = 5  # pixels
43_RADIUS_RTOL = 0.1  # 10% tolerance Video/Preview circle size
44_RECORDING_DURATION = 2  # seconds
45_ZOOM_COMP_MAX_THRESH = 1.15
46_ZOOM_RATIO = 2
47
48
49class PreviewVideoZoomMatchTest(its_base_test.ItsBaseTest):
50  """Tests if preview matches video output when zooming.
51
52  Preview and video are recorded while do_3a() iterate through
53  different cameras with minimal zoom to zoom factor 1.5x.
54
55  The recorded preview and video output are processed to dump all
56  of the frames to PNG files. Camera movement in zoom is extracted
57  from frames by determining if the size of the circle being recorded
58  increases as zoom factor increases. Test is a PASS if both recordings
59  match in zoom factors.
60  """
61
62  def test_preview_video_zoom_match(self):
63    video_test_data = {}
64    preview_test_data = {}
65    log_path = self.log_path
66    with its_session_utils.ItsSession(
67        device_id=self.dut.serial,
68        camera_id=self.camera_id,
69        hidden_physical_id=self.hidden_physical_id) as cam:
70      props = cam.get_camera_properties()
71      props = cam.override_with_hidden_physical_camera_props(props)
72      debug = self.debug_mode
73
74      def _do_preview_recording(cam, resolution, zoom_ratio):
75        """Record a new set of data from the device.
76
77        Captures camera preview frames while the camera is zooming.
78
79        Args:
80          cam: camera object
81          resolution: str; preview resolution (ex. '1920x1080')
82          zoom_ratio: float; zoom ratio
83
84        Returns:
85          preview recording object as described by cam.do_basic_recording
86        """
87
88        # Record previews
89        preview_recording_obj = cam.do_preview_recording(
90            resolution, _RECORDING_DURATION, False, zoom_ratio=zoom_ratio)
91        logging.debug('Preview_recording_obj: %s', preview_recording_obj)
92        logging.debug('Recorded output path for preview: %s',
93                      preview_recording_obj['recordedOutputPath'])
94
95        # Grab and rename the preview recordings from the save location on DUT
96        self.dut.adb.pull(
97            [preview_recording_obj['recordedOutputPath'], log_path])
98        preview_file_name = (
99            preview_recording_obj['recordedOutputPath'].split('/')[-1])
100        logging.debug('recorded preview name: %s', preview_file_name)
101
102        return preview_file_name
103
104      def _do_video_recording(cam, profile_id, quality, zoom_ratio):
105        """Record a new set of data from the device.
106
107        Captures camera video frames while the camera is zooming per zoom_ratio.
108
109        Args:
110          cam: camera object
111          profile_id: int; profile id corresponding to the quality level
112          quality: str; video recording quality such as High, Low, 480P
113          zoom_ratio: float; zoom ratio.
114
115        Returns:
116          video recording object as described by cam.do_basic_recording
117        """
118
119        # Record videos
120        video_recording_obj = cam.do_basic_recording(
121            profile_id, quality, _RECORDING_DURATION, 0, zoom_ratio=zoom_ratio)
122        logging.debug('Video_recording_obj: %s', video_recording_obj)
123        logging.debug('Recorded output path for video: %s',
124                      video_recording_obj['recordedOutputPath'])
125
126        # Grab and rename the video recordings from the save location on DUT
127        self.dut.adb.pull(
128            [video_recording_obj['recordedOutputPath'], log_path])
129        video_file_name = (
130            video_recording_obj['recordedOutputPath'].split('/')[-1])
131        logging.debug('recorded video name: %s', video_file_name)
132
133        return video_file_name
134
135      # Find zoom range
136      z_range = props['android.control.zoomRatioRange']
137
138      # Skip unless camera has zoom ability
139      first_api_level = its_session_utils.get_first_api_level(
140          self.dut.serial)
141      camera_properties_utils.skip_unless(
142          z_range and first_api_level >= its_session_utils.ANDROID14_API_LEVEL)
143      logging.debug('Testing zoomRatioRange: %s', z_range)
144
145      # Determine zoom factors
146      z_min = z_range[0]
147      camera_properties_utils.skip_unless(
148          float(z_range[-1]) >= z_min * zoom_capture_utils.ZOOM_MIN_THRESH)
149      zoom_ratios_to_be_tested = [z_min]
150      if z_min < 1.0:
151        zoom_ratios_to_be_tested.append(float(_ZOOM_RATIO))
152      else:
153        zoom_ratios_to_be_tested.append(float(z_min * 2))
154      logging.debug('Testing zoom ratios: %s', str(zoom_ratios_to_be_tested))
155
156      # Load chart for scene
157      if z_min > _MIN_ZOOM_SCALE_CHART:
158        its_session_utils.load_scene(
159            cam, props, self.scene, self.tablet, self.chart_distance)
160      else:  # Load full-scale chart for small zoom factor
161        its_session_utils.load_scene(
162            cam, props, self.scene, self.tablet,
163            its_session_utils.CHART_DISTANCE_NO_SCALING)
164
165      # Find supported preview/video sizes, and their smallest and common size
166      supported_preview_sizes = cam.get_supported_preview_sizes(self.camera_id)
167      supported_video_qualities = cam.get_supported_video_qualities(
168          self.camera_id)
169      logging.debug(
170          'Supported video profiles and ID: %s', supported_video_qualities)
171      common_size, common_video_quality = (
172          video_processing_utils.get_lowest_common_preview_video_size(
173              supported_preview_sizes, supported_video_qualities,
174              _MIN_RESOLUTION_AREA
175          )
176      )
177
178      # Start video recording over minZoom and 2x Zoom
179      for quality_profile_id_pair in supported_video_qualities:
180        quality = quality_profile_id_pair.split(':')[0]
181        profile_id = quality_profile_id_pair.split(':')[-1]
182        if quality == common_video_quality:
183          for i, z in enumerate(zoom_ratios_to_be_tested):
184            logging.debug('Testing video recording for quality: %s', quality)
185            req = capture_request_utils.auto_capture_request()
186            req['android.control.zoomRatio'] = z
187            cam.do_3a(zoom_ratio=z)
188            logging.debug('Zoom ratio: %.2f', z)
189
190            # Determine focal length of camera through capture
191            cap = cam.do_capture(
192                req, {'format': 'yuv'})
193            cap_fl = cap['metadata']['android.lens.focalLength']
194            logging.debug('Camera focal length: %.2f', cap_fl)
195
196            # Determine width and height of video
197            size = common_size.split('x')
198            width = int(size[0])
199            height = int(size[1])
200
201            # Start video recording
202            video_file_name = _do_video_recording(
203                cam, profile_id, quality, zoom_ratio=z)
204
205            # Get key frames from the video recording
206            video_img = (
207                video_processing_utils.extract_last_key_frame_from_recording(
208                    log_path, video_file_name))
209
210            # Find the center circle in video img
211            img_name_stem = os.path.join(log_path, 'video_zoomRatio')
212            video_img_name = (
213                f'{img_name_stem}_{z:.2f}_{quality}_circle.png')
214            circle = zoom_capture_utils.find_center_circle(
215                video_img, video_img_name, [width, height],
216                z, z_min, circlish_rtol=_CIRCLISH_RTOL,
217                min_circle_pts=_MIN_CIRCLE_PTS, debug=debug)
218            logging.debug('Recorded video name: %s', video_file_name)
219
220            video_test_data[i] = {'z': z, 'circle': circle}
221
222      # Start preview recording over minZoom and maxZoom
223      for size in supported_preview_sizes:
224        if size == common_size:
225          for i, z in enumerate(zoom_ratios_to_be_tested):
226            cam.do_3a(zoom_ratio=z)
227            preview_file_name = _do_preview_recording(
228                cam, size, zoom_ratio=z)
229
230            # Define width and height from size
231            width = int(size.split('x')[0])
232            height = int(size.split('x')[1])
233
234            # Get key frames from the preview recording
235            preview_img = (
236                video_processing_utils.extract_last_key_frame_from_recording(
237                    log_path, preview_file_name))
238
239            # If front camera, flip preview image to match camera capture
240            if (props['android.lens.facing'] ==
241                camera_properties_utils.LENS_FACING['FRONT']):
242              img_name_stem = os.path.join(log_path, 'flipped_preview')
243              img_name = (
244                  f'{img_name_stem}_zoomRatio_{z:.2f}.'
245                  f'{zoom_capture_utils.JPEG_STR}')
246              preview_img = (
247                  preview_processing_utils.mirror_preview_image_by_sensor_orientation(
248                      props['android.sensor.orientation'], preview_img))
249              image_processing_utils.write_image(preview_img / 255, img_name)
250            else:
251              img_name_stem = os.path.join(log_path, 'rear_preview')
252
253            # Find the center circle in preview img
254            preview_img_name = (
255                f'{img_name_stem}_zoomRatio_{z:.2f}_{size}_circle.png')
256            circle = zoom_capture_utils.find_center_circle(
257                preview_img, preview_img_name, [width, height],
258                z, z_min, circlish_rtol=_CIRCLISH_RTOL,
259                min_circle_pts=_MIN_CIRCLE_PTS, debug=debug)
260
261            preview_test_data[i] = {'z': z, 'circle': circle}
262
263      # Compare size and center of preview's circle to video's circle
264      preview_radius = {}
265      video_radius = {}
266      z_idx = {}
267      zoom_factor = {}
268      preview_radius[_MIN_STR] = (preview_test_data[0]['circle'][_CIRCLE_R])
269      video_radius[_MIN_STR] = (video_test_data[0]['circle'][_CIRCLE_R])
270      preview_radius[_MAX_STR] = (preview_test_data[1]['circle'][_CIRCLE_R])
271      video_radius[_MAX_STR] = (video_test_data[1]['circle'][_CIRCLE_R])
272      z_idx[_MIN_STR] = (
273          preview_radius[_MIN_STR] / video_radius[_MIN_STR])
274      z_idx[_MAX_STR] = (
275          preview_radius[_MAX_STR] / video_radius[_MAX_STR])
276      z_comparison = z_idx[_MAX_STR] / z_idx[_MIN_STR]
277      zoom_factor[_MIN_STR] = preview_test_data[0]['z']
278      zoom_factor[_MAX_STR] = preview_test_data[1]['z']
279
280      # Compare preview circle's center with video circle's center
281      preview_circle_x = preview_test_data[1]['circle'][_CIRCLE_X]
282      video_circle_x = video_test_data[1]['circle'][_CIRCLE_X]
283      preview_circle_y = preview_test_data[1]['circle'][_CIRCLE_Y]
284      video_circle_y = video_test_data[1]['circle'][_CIRCLE_Y]
285      circles_offset_x = math.isclose(preview_circle_x, video_circle_x,
286                                      abs_tol=_OFFSET_TOL)
287      circles_offset_y = math.isclose(preview_circle_y, video_circle_y,
288                                      abs_tol=_OFFSET_TOL)
289      logging.debug('Preview circle x: %.2f, Video circle x: %.2f'
290                    ' Preview circle y: %.2f, Video circle y: %.2f',
291                    preview_circle_x, video_circle_x,
292                    preview_circle_y, video_circle_y)
293      logging.debug('Preview circle r: %.2f, Preview circle r zoom: %.2f'
294                    ' Video circle r: %.2f, Video circle r zoom: %.2f'
295                    ' centers offset x: %s, centers offset y: %s',
296                    preview_radius[_MIN_STR], preview_radius[_MAX_STR],
297                    video_radius[_MIN_STR], video_radius[_MAX_STR],
298                    circles_offset_x, circles_offset_y)
299      if not circles_offset_x or not circles_offset_y:
300        raise AssertionError('Preview and video output do not match! '
301                             'Preview and video circles offset is too great')
302
303      # Check zoom ratio by size of circles before and after zoom
304      for radius_ratio in z_idx.values():
305        if not math.isclose(radius_ratio, 1, rel_tol=_RADIUS_RTOL):
306          raise AssertionError('Preview and video output do not match! '
307                               f'Radius ratio: {radius_ratio:.2f}')
308
309      if z_comparison > _ZOOM_COMP_MAX_THRESH:
310        raise AssertionError('Preview and video output do not match! '
311                             f'Zoom ratio difference: {z_comparison:.2f}')
312
313if __name__ == '__main__':
314  test_runner.main()
315