• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 night extension is activated correctly when requested."""
15
16
17import logging
18import os.path
19import time
20
21from mobly import test_runner
22import numpy as np
23
24import its_base_test
25import camera_properties_utils
26import capture_request_utils
27import image_processing_utils
28import its_session_utils
29import lighting_control_utils
30import opencv_processing_utils
31
32_NAME = os.path.splitext(os.path.basename(__file__))[0]
33_EXTENSION_NIGHT = 4  # CameraExtensionCharacteristics.EXTENSION_NIGHT
34_TABLET_BRIGHTNESS = '12'  # Highest minimum brightness on a supported tablet
35_TAP_COORDINATES = (500, 500)  # Location to tap tablet screen via adb
36_TEST_REQUIRED_MPC = 34
37_MIN_AREA = 0.001  # Circle must be >= 0.1% of image size
38_WHITE = 255
39
40_FMT_NAME = 'yuv'  # To detect noise without conversion to RGB
41_IMAGE_FORMAT_YUV_420_888_INT = 35
42
43_DOT_INTENSITY_DIFF_TOL = 20  # Min diff between dot/circle intensities [0:255]
44_DURATION_DIFF_TOL = 0.5  # Night mode ON captures must take 0.5 seconds longer
45_INTENSITY_IMPROVEMENT_TOL = 1.1  # Night mode ON captures must be 10% brighter
46_IDEAL_INTENSITY_IMPROVEMENT = 2.5  # Skip noise check if images 2.5x brighter
47
48_R_STRING = 'r'
49_X_STRING = 'x'
50_Y_STRING = 'y'
51
52
53def _get_dots_from_circle(circle):
54  """Calculates dot locations using the surrounding outer circle.
55
56  Args:
57    circle: dictionary; outer circle
58  Returns:
59    List of dict; inner circles (dots)
60  """
61  circle_x = int(circle[_X_STRING])
62  circle_y = int(circle[_Y_STRING])
63  offset = int(circle[_R_STRING] // 2)  # Dot location from scene definition
64  dots = [
65      {_X_STRING: circle_x + offset, _Y_STRING: circle_y - offset},
66      {_X_STRING: circle_x - offset, _Y_STRING: circle_y - offset},
67      {_X_STRING: circle_x - offset, _Y_STRING: circle_y + offset},
68      {_X_STRING: circle_x + offset, _Y_STRING: circle_y + offset},
69  ]
70  return dots
71
72
73def _convert_captures(cap, file_stem=None):
74  """Obtains y plane and numpy image from a capture.
75
76  Args:
77    cap: A capture object as returned by its_session_utils.do_capture.
78    file_stem: str; location and name to save files.
79  Returns:
80    Tuple of y_plane, numpy image.
81  """
82  y, _, _ = image_processing_utils.convert_capture_to_planes(cap)
83  img = image_processing_utils.convert_capture_to_rgb_image(cap)
84  if file_stem:
85    image_processing_utils.write_image(img, f'{file_stem}.jpg')
86  return y, image_processing_utils.convert_image_to_uint8(img)
87
88
89def _check_dot_intensity_diff(night_img, night_y):
90  """Checks the difference between circle and dot intensities with Night ON.
91
92  This is an optional check, and a successful result can replace the
93  overall intensity check.
94
95  Args:
96    night_img: numpy image from a capture with night mode ON.
97    night_y: y_plane from a capture with night mode ON.
98
99  Returns:
100    True if diff between circle and dot intensities is significant.
101  """
102  try:
103    night_circle = opencv_processing_utils.find_circle(
104        night_img,
105        'night_dot_intensity_check.png',
106        _MIN_AREA,
107        _WHITE,
108    )
109  except AssertionError as e:
110    logging.debug(e)
111    return False
112  night_circle_center_mean = np.mean(
113      night_img[night_circle[_Y_STRING], night_circle[_X_STRING]])
114  night_dots = _get_dots_from_circle(night_circle)
115
116  # Skip the first dot, which is of a different intensity
117  night_light_gray_dots_mean = np.mean(
118      [
119          night_y[night_dots[i][_Y_STRING], night_dots[i][_X_STRING]]
120          for i in range(1, len(night_dots))
121      ]
122  )
123
124  night_dot_intensity_diff = (
125      night_circle_center_mean -
126      night_light_gray_dots_mean
127  )
128  logging.debug('With night extension ON, the difference between white '
129                'circle intensity and non-orientation dot intensity was %.2f.',
130                night_dot_intensity_diff)
131  return night_dot_intensity_diff > _DOT_INTENSITY_DIFF_TOL
132
133
134def _check_overall_intensity(night_img, no_night_img):
135  """Checks that overall intensity significantly improves with night mode ON.
136
137  All implementations must result in an increase in intensity of at least
138  _INTENSITY_IMPROVEMENT_TOL. _IDEAL_INTENSITY_IMPROVEMENT is the minimum
139  improvement to waive the edge noise check.
140
141  Args:
142    night_img: numpy image taken with night mode ON
143    no_night_img: numpy image taken with night mode OFF
144  Returns:
145    True if intensity has increased enough to waive the edge noise check.
146  """
147  night_mean = np.mean(night_img)
148  no_night_mean = np.mean(no_night_img)
149  overall_intensity_ratio = night_mean / no_night_mean
150  logging.debug('Night mode ON overall mean: %.2f', night_mean)
151  logging.debug('Night mode OFF overall mean: %.2f', no_night_mean)
152  if overall_intensity_ratio < _INTENSITY_IMPROVEMENT_TOL:
153    raise AssertionError('Night mode ON image was not significantly more '
154                         'intense than night mode OFF image! '
155                         f'Ratio: {overall_intensity_ratio:.2f}, '
156                         f'Expected: {_INTENSITY_IMPROVEMENT_TOL}')
157  return overall_intensity_ratio > _IDEAL_INTENSITY_IMPROVEMENT
158
159
160class NightExtensionTest(its_base_test.ItsBaseTest):
161  """Tests night extension under dark lighting conditions.
162
163  When lighting conditions are dark:
164  1. Sets tablet to highest brightness where the orientation circle is visible.
165  2. Takes capture with night extension ON using an auto capture request.
166  3. Takes capture with night extension OFF using an auto capture request.
167  Verifies that the capture with night mode ON:
168    * takes longer
169    * is brighter OR improves appearance of scene artifacts
170  """
171
172  def _time_and_take_captures(self, cam, req, out_surfaces,
173                              use_extensions=True):
174    """Find maximum brightness at which orientation circle in scene is visible.
175
176    Uses binary search on a range of (0, default_brightness), where visibility
177    is defined by an intensity comparison with the center of the outer circle.
178
179    Args:
180      cam: its_session_utils object.
181      req: capture request.
182      out_surfaces: dictionary of output surfaces.
183      use_extensions: bool; whether extension capture should be used.
184    Returns:
185      Tuple of float; capture duration, capture object.
186    """
187    start_of_capture = time.time()
188    if use_extensions:
189      logging_prefix = 'Night mode ON'
190      cap = cam.do_capture_with_extensions(req, _EXTENSION_NIGHT, out_surfaces)
191    else:
192      logging_prefix = 'Night mode OFF'
193      cap = cam.do_capture(req, out_surfaces)
194    end_of_capture = time.time()
195    capture_duration = end_of_capture - start_of_capture
196    logging.debug('%s capture took %f seconds',
197                  logging_prefix, capture_duration)
198    metadata = cap['metadata']
199    logging.debug('%s exposure time: %s', logging_prefix,
200                  metadata['android.sensor.exposureTime'])
201    logging.debug('%s sensitivity: %s', logging_prefix,
202                  metadata['android.sensor.sensitivity'])
203    return capture_duration, cap
204
205  def test_night_extension(self):
206    # Handle subdirectory
207    self.scene = 'scene_night'
208    with its_session_utils.ItsSession(
209        device_id=self.dut.serial,
210        camera_id=self.camera_id,
211        hidden_physical_id=self.hidden_physical_id) as cam:
212      props = cam.get_camera_properties()
213      props = cam.override_with_hidden_physical_camera_props(props)
214      test_name = os.path.join(self.log_path, _NAME)
215
216      # Determine camera supported extensions
217      supported_extensions = cam.get_supported_extensions(self.camera_id)
218      logging.debug('Supported extensions: %s', supported_extensions)
219
220      # Check media performance class
221      should_run = _EXTENSION_NIGHT in supported_extensions
222      media_performance_class = its_session_utils.get_media_performance_class(
223          self.dut.serial)
224      if (media_performance_class >= _TEST_REQUIRED_MPC and
225          cam.is_primary_camera() and
226          not should_run):
227        its_session_utils.raise_mpc_assertion_error(
228            _TEST_REQUIRED_MPC, _NAME, media_performance_class)
229
230      # Check SKIP conditions
231      camera_properties_utils.skip_unless(should_run)
232
233      tablet_name_unencoded = self.tablet.adb.shell(
234          ['getprop', 'ro.build.product']
235      )
236      tablet_name = str(tablet_name_unencoded.decode('utf-8')).strip()
237      logging.debug('Tablet name: %s', tablet_name)
238
239      if tablet_name == its_session_utils.LEGACY_TABLET_NAME:
240        raise AssertionError(f'Incompatible tablet! Please use a tablet with '
241                             'display brightness of at least '
242                             f'{its_session_utils.DEFAULT_TABLET_BRIGHTNESS} '
243                             'according to '
244                             f'{its_session_utils.TABLET_REQUIREMENTS_URL}.')
245
246      # Establish connection with lighting controller
247      arduino_serial_port = lighting_control_utils.lighting_control(
248          self.lighting_cntl, self.lighting_ch)
249
250      # Turn OFF lights to darken scene
251      lighting_control_utils.set_lighting_state(
252          arduino_serial_port, self.lighting_ch, 'OFF')
253
254      # Check that tablet is connected and turn it off to validate lighting
255      if self.tablet:
256        lighting_control_utils.turn_off_device(self.tablet)
257      else:
258        raise AssertionError('Test must be run with tablet.')
259
260      # Validate lighting, then setup tablet
261      cam.do_3a(do_af=False)
262      cap = cam.do_capture(
263          capture_request_utils.auto_capture_request(), cam.CAP_YUV)
264      y_plane, _, _ = image_processing_utils.convert_capture_to_planes(cap)
265      its_session_utils.validate_lighting(
266          y_plane, self.scene, state='OFF', log_path=self.log_path)
267      self.setup_tablet()
268
269      its_session_utils.load_scene(
270          cam, props, self.scene, self.tablet, self.chart_distance,
271          lighting_check=False, log_path=self.log_path)
272
273      # Tap tablet to remove gallery buttons
274      if self.tablet:
275        self.tablet.adb.shell(
276            f'input tap {_TAP_COORDINATES[0]} {_TAP_COORDINATES[1]}')
277
278      # Determine capture width and height
279      width, height = None, None
280      capture_sizes = capture_request_utils.get_available_output_sizes(
281          _FMT_NAME, props)
282      extension_capture_sizes_str = cam.get_supported_extension_sizes(
283          self.camera_id, _EXTENSION_NIGHT, _IMAGE_FORMAT_YUV_420_888_INT
284      )
285      extension_capture_sizes = [
286          tuple(int(size_part) for size_part in s.split(_X_STRING))
287          for s in extension_capture_sizes_str
288      ]
289      # Extension capture sizes are ordered in ascending area order by default
290      extension_capture_sizes.reverse()
291      logging.debug('Capture sizes: %s', capture_sizes)
292      logging.debug('Extension capture sizes: %s', extension_capture_sizes)
293      width, height = extension_capture_sizes[0]
294
295      # Set tablet brightness to darken scene
296      self.set_screen_brightness(_TABLET_BRIGHTNESS)
297
298      file_stem = f'{test_name}_{_FMT_NAME}_{width}x{height}'
299      out_surfaces = {'format': _FMT_NAME, 'width': width, 'height': height}
300      req = capture_request_utils.auto_capture_request()
301
302      # Take auto capture with night mode on
303      logging.debug('Taking auto capture with night mode ON')
304      cam.do_3a()
305      night_capture_duration, night_cap = self._time_and_take_captures(
306          cam, req, out_surfaces, use_extensions=True)
307      night_y, night_img = _convert_captures(night_cap, f'{file_stem}_night')
308
309      # Take auto capture with night mode OFF
310      logging.debug('Taking auto capture with night mode OFF')
311      cam.do_3a()
312      no_night_capture_duration, no_night_cap = self._time_and_take_captures(
313          cam, req, out_surfaces, use_extensions=False)
314      _, no_night_img = _convert_captures(
315          no_night_cap, f'{file_stem}_no_night')
316
317      # Assert correct behavior
318      logging.debug('Comparing capture time with night mode ON/OFF')
319      duration_diff = night_capture_duration - no_night_capture_duration
320      if duration_diff < _DURATION_DIFF_TOL:
321        raise AssertionError('Night mode ON capture did not take '
322                             'significantly more time than '
323                             'night mode OFF capture! '
324                             f'Difference: {duration_diff:.2f}, '
325                             f'Expected: {_DURATION_DIFF_TOL}')
326
327      logging.debug('Checking that dot intensities with Night ON match the '
328                    'expected values from the scene')
329      # Normalize y planes to [0:255]
330      dot_intensities_acceptable = _check_dot_intensity_diff(
331          night_img, night_y * 255)
332
333      if not dot_intensities_acceptable:
334        logging.debug('Comparing overall intensity of capture with '
335                      'night mode ON/OFF')
336        much_higher_intensity = _check_overall_intensity(
337            night_img, no_night_img)
338        if not much_higher_intensity:
339          logging.warning(
340              'Improvement in intensity was smaller than expected.')
341
342if __name__ == '__main__':
343  test_runner.main()
344