1# Copyright 2024 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 zoom ratio scales circle sizes correctly."""
15
16import logging
17import os.path
18import subprocess
19
20import cv2
21from mobly import test_runner
22
23import its_base_test
24import camera_properties_utils
25import its_session_utils
26import preview_processing_utils
27import video_processing_utils
28import zoom_capture_utils
29
30
31_CIRCLISH_RTOL = 0.1  # contour area vs ideal circle area pi*((w+h)/4)**2
32_CRF = 23
33_CV2_RED = (0, 0, 255)  # color (B, G, R) in cv2 to draw lines
34_FPS = 30
35_MP4V = 'mp4v'
36_NAME = os.path.splitext(os.path.basename(__file__))[0]
37_NUM_STEPS = 50
38
39
40def compress_video(input_filename, output_filename, crf=_CRF):
41  """Compresses the given video using ffmpeg."""
42
43  ffmpeg_cmd = [
44      'ffmpeg',
45      '-i', input_filename,   # Input file
46      '-c:v', 'libx264',      # Use H.264 codec
47      '-crf', str(crf),       # Set Constant Rate Factor (adjust for quality)
48      '-preset', 'medium',    # Encoding speed/compression balance
49      '-c:a', 'copy',         # Copy audio stream without re-encoding
50      output_filename         # Output file
51  ]
52
53  with open(os.devnull, 'w') as devnull:
54    subprocess.run(ffmpeg_cmd, stdout=devnull,
55                   stderr=subprocess.STDOUT, check=False)
56
57
58class PreviewZoomTest(its_base_test.ItsBaseTest):
59  """Verify zoom ratio of preview frames matches values in TotalCaptureResult."""
60
61  def test_preview_zoom(self):
62    log_path = self.log_path
63    video_processing_utils.log_ffmpeg_version()
64
65    with its_session_utils.ItsSession(
66        device_id=self.dut.serial,
67        camera_id=self.camera_id,
68        hidden_physical_id=self.hidden_physical_id) as cam:
69
70      debug = self.debug_mode
71
72      props = cam.get_camera_properties()
73      props = cam.override_with_hidden_physical_camera_props(props)
74      camera_properties_utils.skip_unless(
75          camera_properties_utils.zoom_ratio_range(props))
76
77      # Load chart for scene
78      its_session_utils.load_scene(
79          cam, props, self.scene, self.tablet, self.chart_distance)
80
81      # Raise error if not FRONT or REAR facing camera
82      camera_properties_utils.check_front_or_rear_camera(props)
83
84      # set TOLs based on camera and test rig params
85      if camera_properties_utils.logical_multi_camera(props):
86        test_tols, _ = zoom_capture_utils.get_test_tols_and_cap_size(
87            cam, props, self.chart_distance, debug)
88      else:
89        test_tols = {}
90        fls = props['android.lens.info.availableFocalLengths']
91        for fl in fls:
92          test_tols[fl] = (zoom_capture_utils.RADIUS_RTOL,
93                           zoom_capture_utils.OFFSET_RTOL)
94      logging.debug('Threshold levels to be used for testing: %s', test_tols)
95
96      # get max preview size
97      preview_size = preview_processing_utils.get_max_preview_test_size(
98          cam, self.camera_id)
99      size = [int(x) for x in preview_size.split('x')]
100      logging.debug('preview_size = %s', preview_size)
101      logging.debug('size = %s', size)
102
103      # Determine test zoom range and step size
104      z_range = props['android.control.zoomRatioRange']
105      logging.debug('z_range = %s', str(z_range))
106      z_min, z_max, z_step_size = zoom_capture_utils.get_preview_zoom_params(
107          z_range, _NUM_STEPS)
108      camera_properties_utils.skip_unless(
109          z_max >= z_min * zoom_capture_utils.ZOOM_MIN_THRESH)
110
111      # recording preview
112      capture_results, file_list = (
113          preview_processing_utils.preview_over_zoom_range(
114              self.dut, cam, preview_size, z_min, z_max, z_step_size, log_path)
115      )
116
117      test_data = []
118      test_data_index = 0
119      # Initialize video writer
120      fourcc = cv2.VideoWriter_fourcc(*_MP4V)
121      uncompressed_video = os.path.join(log_path,
122                                        'output_frames_uncompressed.mp4')
123      out = cv2.VideoWriter(uncompressed_video, fourcc, _FPS,
124                            (size[0], size[1]))
125
126      for capture_result, img_name in zip(capture_results, file_list):
127        z = float(capture_result['android.control.zoomRatio'])
128        if camera_properties_utils.logical_multi_camera(props):
129          phy_id = capture_result['android.logicalMultiCamera.activePhysicalId']
130        else:
131          phy_id = None
132
133        # read image
134        img_bgr = cv2.imread(os.path.join(log_path, img_name))
135
136        # add path to image name
137        img_path = f'{os.path.join(self.log_path, img_name)}'
138
139        # determine radius tolerance of capture
140        cap_fl = capture_result['android.lens.focalLength']
141        radius_tol, offset_tol = test_tols.get(
142            cap_fl,
143            (zoom_capture_utils.RADIUS_RTOL, zoom_capture_utils.OFFSET_RTOL)
144        )
145
146        # Scale circlish RTOL for low zoom ratios
147        if z < 1:
148          circlish_rtol = _CIRCLISH_RTOL / z
149        else:
150          circlish_rtol = _CIRCLISH_RTOL
151
152        # Find the center circle in img and check if it's cropped
153        circle = zoom_capture_utils.find_center_circle(
154            img_bgr, img_path, size, z, z_min, circlish_rtol=circlish_rtol,
155            debug=debug, draw_color=_CV2_RED, write_img=False)
156
157        # Zoom is too large to find center circle
158        if circle is None:
159          logging.error('Unable to detect circle in %s', img_path)
160          break
161
162        out.write(img_bgr)
163        # Remove png file
164        its_session_utils.remove_file(img_path)
165
166        test_data.append(
167            zoom_capture_utils.ZoomTestData(
168                result_zoom=z,
169                circle=circle,
170                radius_tol=radius_tol,
171                offset_tol=offset_tol,
172                focal_length=cap_fl,
173                physical_id=phy_id
174            )
175        )
176
177        logging.debug('test_data[%d] = %s', test_data_index,
178                      test_data[test_data_index])
179        test_data_index = test_data_index + 1
180
181      out.release()
182
183      # --- Compress Video ---
184      compressed_video = os.path.join(log_path, 'output_frames.mp4')
185      compress_video(uncompressed_video, compressed_video)
186
187      os.remove(uncompressed_video)
188
189      plot_name_stem = f'{os.path.join(log_path, _NAME)}'
190      if not zoom_capture_utils.verify_preview_zoom_results(
191          test_data, size, z_max, z_min, z_step_size, plot_name_stem):
192        raise AssertionError(f'{_NAME} failed! Check test_log.DEBUG for errors')
193
194if __name__ == '__main__':
195  test_runner.main()
196