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