| # Copyright 2024 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. |
| """Utility functions for multi camera switch tests.""" |
| |
| import logging |
| import math |
| import numpy |
| |
| import camera_properties_utils |
| import image_processing_utils |
| import image_fov_utils |
| import its_session_utils |
| import opencv_processing_utils |
| |
| |
| _CH_FULL_SCALE = 255 |
| _CONTROL_AF_STATE_PASSIVE_SCAN = 1 |
| _CONTROL_AF_STATE_PASSIVE_FOCUSED = 2 |
| _CONVERGED_STATE = 2 |
| |
| _AF_CONVERGED_STATE = [_CONTROL_AF_STATE_PASSIVE_SCAN, |
| _CONTROL_AF_STATE_PASSIVE_FOCUSED] |
| |
| |
| def check_orientation_and_flip(props, img, img_name_stem, suffix): |
| """Checks the sensor orientation and flips image. |
| |
| The preview stream captures are flipped based on the sensor |
| orientation while using the front camera. In such cases, check the |
| sensor orientation and flip the image if needed. |
| |
| Args: |
| props: obj; camera properties object. |
| img: numpy array; image. |
| img_name_stem: str; prefix for the img name to be saved. |
| suffix: str; suffix for naming image. |
| Returns: |
| numpy array of the image. |
| """ |
| img = image_processing_utils.mirror_preview_image_by_sensor_orientation( |
| props['android.sensor.orientation'], img) |
| image_processing_utils.write_image(img / _CH_FULL_SCALE, |
| f'{img_name_stem}_{suffix}.png') |
| return img |
| |
| |
| def do_ae_check(img1, img2, file_stem, patch_color, props, suffix1, |
| suffix2, rel_tol, abs_tol): |
| """Check two images' luma change is within specified tolerance. |
| |
| Args: |
| img1: first image. |
| img2: second image. |
| file_stem: str; path to file. |
| patch_color: str; color of the patch to be tested. |
| props: obj; camera properties object. |
| suffix1: str; suffix for the first image file name. |
| suffix2: str; suffix for the second image file name. |
| rel_tol: float; relative threshold for delta between brightness. |
| abs_tol: float; absolute threshold for delta between brightness. |
| Returns: |
| failed_ae_msg: str; failed AE check messages if any. None otherwise. |
| y1_avg: float; y_avg value for the first image. |
| y2_avg: float; y_avg value for the second image. |
| """ |
| failed_ae_msg = [] |
| y1 = opencv_processing_utils.extract_y( |
| img1, f'{file_stem}_{suffix1}_y.png') |
| y1_avg = numpy.average(y1) |
| logging.debug('%s y avg: %.4f', suffix1, y1_avg) |
| |
| y2 = opencv_processing_utils.extract_y( |
| img2, f'{file_stem}_{suffix2}_y.png') |
| y2_avg = numpy.average(y2) |
| logging.debug('%s y avg: %.4f', suffix2, y2_avg) |
| y_avg_change_percent = (abs(y2_avg - y1_avg) / y1_avg) * 100 |
| logging.debug('Y avg change percentage: %.4f', y_avg_change_percent) |
| |
| if not math.isclose(y1_avg, y2_avg, rel_tol=rel_tol, abs_tol=abs_tol): |
| failed_msg = ('Y avg change is greater than threshold value for ' |
| f'patches: {patch_color} ' |
| f'diff: {abs(y2_avg - y1_avg):.4f} ' |
| f'ATOL: {abs_tol} ' |
| f'RTOL: {rel_tol} ' |
| f'{suffix1} y avg: {y1_avg:.4f} ' |
| f'{suffix2} y avg: {y2_avg:.4f} ') |
| # If the device supports both ae_regions and awb_regions, |
| # then fail for all 4 patches. |
| if camera_properties_utils.awb_regions(props): |
| failed_ae_msg.append(failed_msg) |
| else: |
| # If only ae_regions is supported, then fail only for gray patch |
| if patch_color == 'gray': |
| failed_ae_msg.append(failed_msg) |
| |
| return failed_ae_msg, y1_avg, y2_avg |
| |
| |
| def do_af_check(img1, img2, suffix1, suffix2): |
| """Checks the AF behavior between two images. |
| |
| Args: |
| img1: image captured from first camera. |
| img2: image captured from second camera. |
| suffix1: str; suffix used to save the first image. |
| suffix2: str; suffix used to save the second image. |
| Returns: |
| failed_af_msg: Failed AF check messages if any. None otherwise. |
| sharpness1: sharpness value for first image. |
| sharpness2: sharpness value for second image. |
| """ |
| failed_af_msg = [] |
| sharpness1 = image_processing_utils.compute_image_sharpness(img1) |
| logging.debug('Sharpness for %s image: %.2f', suffix1, sharpness1) |
| sharpness2 = image_processing_utils.compute_image_sharpness(img2) |
| logging.debug('Sharpness for %s image: %.2f', suffix2, sharpness2) |
| |
| if sharpness2 < sharpness1: |
| failed_af_msg.append(f'Sharpness should be higher for {suffix2} lens. ' |
| f'{suffix2} sharpness: {sharpness2:.4f} ' |
| f'{suffix1} sharpness: {sharpness1:.4f}') |
| return failed_af_msg, sharpness1, sharpness2 |
| |
| |
| def do_awb_check(img1, img2, c_atol, patch_color, suffix1, suffix2): |
| """Checks total chroma (saturation) difference between two images. |
| |
| Args: |
| img1: first image. |
| img2: second image. |
| c_atol: float; threshold for delta C. |
| patch_color: str; color of the patch to be tested. |
| suffix1: str; suffix for the first image. |
| suffix2: str; suffix for the second image. |
| Returns: |
| failed_awb_msg: failed AWB check messages or None. |
| """ |
| failed_awb_msg = [] |
| l1, a1, b1 = image_processing_utils.get_lab_means(img1, suffix1) |
| l2, a2, b2 = image_processing_utils.get_lab_means(img2, suffix2) |
| |
| # Calculate Delta C |
| delta_c = numpy.sqrt(abs(a1 - a2)**2 + abs(b1 - b2)**2) |
| logging.debug('Delta C: %.4f', delta_c) |
| |
| if delta_c > c_atol: |
| failed_awb_msg.append('Delta C is greater than the threshold value for ' |
| f'patch: {patch_color} ' |
| f'Delta C ATOL: {c_atol} ' |
| f'Delta C: {delta_c:.4f} ' |
| f'{suffix1} L, a, b means: {l1:.4f}, ' |
| f'{a1:.4f}, {b1:.4f}' |
| f'{suffix2} L, a, b means: {l2:.4f}, ' |
| f'{a2:.4f}, {b2:.4f}') |
| return failed_awb_msg |
| |
| |
| def extract_main_patch(corners, ids, img_rgb, img_path, suffix): |
| """Extracts the main rectangle patch from the captured frame. |
| |
| Find aruco markers in the captured image and detects if the |
| expected number of aruco markers have been found or not. |
| It then, extracts the main rectangle patch and saves it |
| without the aruco markers in it. |
| |
| Args: |
| corners: list of detected corners. |
| ids: list of int ids for each ArUco markers in the input_img. |
| img_rgb: An openCV image in RGB order. |
| img_path: Path to save the image. |
| suffix: str; suffix used to save the image. |
| Returns: |
| rectangle_patch: numpy float image array of the rectangle patch. |
| """ |
| rectangle_patch = opencv_processing_utils.get_patch_from_aruco_markers( |
| img_rgb, corners, ids) |
| patch_path = img_path.with_name( |
| f'{img_path.stem}_{suffix}_patch{img_path.suffix}') |
| image_processing_utils.write_image(rectangle_patch/_CH_FULL_SCALE, patch_path) |
| return rectangle_patch |
| |
| |
| def find_aruco_markers(img, img_path, suffix): |
| """Detect ArUco markers in the input image. |
| |
| Args: |
| img: input img with ArUco markers. |
| img_path: path to save the image. |
| suffix: suffix used to save the image. |
| Returns: |
| corners: list of detected corners. |
| ids: list of int ids for each ArUco markers in the input_img. |
| """ |
| aruco_path = img_path.with_name( |
| f'{img_path.stem}_{suffix}_aruco{img_path.suffix}') |
| corners, ids, _ = opencv_processing_utils.find_aruco_markers( |
| img, aruco_path) |
| return corners, ids |
| |
| |
| def get_error_msg(failed_awb_msg, failed_ae_msg): |
| """"Returns the error message string. |
| |
| Args: |
| failed_awb_msg: list of awb error msgs. |
| failed_ae_msg: list of ae error msgs. |
| Returns: |
| error_msg: str; error_msg string. |
| """ |
| error_msg = '' |
| if failed_awb_msg: |
| error_msg = f'{error_msg}----AWB Check----\n' |
| for msg in failed_awb_msg: |
| error_msg = f'{error_msg}{msg}\n' |
| if failed_ae_msg: |
| error_msg = f'{error_msg}----AE Check----\n' |
| for msg in failed_ae_msg: |
| error_msg = f'{error_msg}{msg}\n' |
| return error_msg |
| |
| |
| def check_lens_switch_conditions(props, first_api_level, zoom_range_lenses, |
| logical_camera_found): |
| """Check the camera properties for lens switch conditions. |
| |
| Camera only switches if 3A converges |
| Args: |
| props: Camera properties dictionary. |
| first_api_level: First API level. |
| zoom_range_lenses: Tuple of two zoom ratio. |
| logical_camera_found: Boolean indicating if logical camera is found. |
| Raises: |
| SkipTest: If the device doesn't support the required properties or API |
| level. |
| """ |
| camera_properties_utils.skip_unless( |
| first_api_level >= its_session_utils.ANDROID16_API_LEVEL and |
| camera_properties_utils.zoom_ratio_range(props) and |
| camera_properties_utils.logical_multi_camera(props) and |
| camera_properties_utils.ae_regions(props) and |
| logical_camera_found) |
| |
| # Check the zoom range |
| zoom_range = props['android.control.zoomRatioRange'] |
| logging.debug('zoomRatioRange: %s', zoom_range) |
| camera_properties_utils.skip_unless( |
| len(zoom_range) > 1 and |
| (zoom_range[0] <= zoom_range_lenses[0] <= zoom_range[1]) and |
| (zoom_range[0] <= zoom_range_lenses[1] <= zoom_range[1])) |
| |
| |
| def find_crossover_point(cam, capture_results): |
| """Find the crossover point where the physical camera changes. |
| |
| Analyze each frame extracted from the recording to detect the point |
| at which the camera's active physical ID changes alongside an |
| increasing zoom ratio. A successful crossover is identified only |
| when the 3A algorithms have converged at the frame where the camera |
| switch occurs. |
| |
| Args: |
| cam: An open device session. |
| capture_results: List of capture results. |
| Returns: |
| A tuple of (lens_changed, counter) |
| lens_changed: Boolean indicating if the lens changed. |
| counter: number of frame where the crossover occurred. |
| Raises: AssertionError if the 3A did not converge at crossover point. |
| """ |
| physical_id_before = None |
| counter = 0 |
| lens_changed = False |
| |
| for capture_result in capture_results: |
| counter += 1 |
| ae_state = capture_result['android.control.aeState'] |
| awb_state = capture_result['android.control.awbState'] |
| af_state = capture_result['android.control.afState'] |
| physical_id = capture_result[ |
| 'android.logicalMultiCamera.activePhysicalId'] |
| zoom_ratio = float(capture_result['android.control.zoomRatio']) |
| logging.debug('Active physical id %s frame %s AE, AWB, AF: %s, %s, %s', |
| physical_id, counter, ae_state, awb_state, af_state) |
| if not physical_id_before: |
| physical_id_before = physical_id |
| if physical_id_before == physical_id: |
| continue |
| physical_props_id_before = cam.get_camera_properties_by_id( |
| physical_id_before |
| ) |
| physical_props_id = cam.get_camera_properties_by_id(physical_id) |
| logging.debug( |
| 'Active physical id %s changed to %s at frame %s and zoom ratio %f', |
| physical_id_before, physical_id, counter, zoom_ratio |
| ) |
| # Avoid getting HAL-simulated camera by checking field of view change |
| camera_fov_before = image_fov_utils.calc_camera_fov_from_metadata( |
| capture_results[counter - 2], physical_props_id_before) |
| camera_fov = image_fov_utils.calc_camera_fov_from_metadata( |
| capture_result, physical_props_id) |
| physical_id_before = physical_id |
| if camera_fov_before != camera_fov: |
| logging.debug('Cameras with different field of view (%s != %s) crossed.', |
| camera_fov_before, camera_fov) |
| print(f'test_multi_camera_switch_crossover_zoom_ratio: {zoom_ratio:.2f}') |
| if (ae_state == awb_state == _CONVERGED_STATE) and ( |
| af_state in _AF_CONVERGED_STATE): |
| lens_changed = True |
| logging.debug('3A converged at crossover. AE, AWB, AF state: ' |
| '%s, %s, %s', ae_state, awb_state, af_state) |
| break |
| else: |
| logging.debug('AE, AWB, AF state: %s, %s, %s', |
| ae_state, awb_state, af_state) |
| raise AssertionError('3A did not converge at crossover.') |
| |
| return lens_changed, counter |
| |
| |
| def get_camera_properties_and_log(cam, capture_results, file_list, counter, |
| lens_suffix1, lens_suffix2): |
| """Get camera properties for the specific cameras and log the information. |
| |
| Args: |
| cam: An open device session. |
| capture_results: List of capture results. |
| file_list: List of captured image files. |
| counter: Counter for the crossover point. |
| lens_suffix1: Suffix for the first camera. |
| lens_suffix2: Suffix for the second camera. |
| Returns: |
| Tuple of camera properties for both cameras. |
| """ |
| # Get data for the second to last frame before the switch happened |
| img1_file = file_list[counter-2] |
| capture_result_img1 = capture_results[counter-2] |
| img1_phy_id = ( |
| capture_result_img1['android.logicalMultiCamera.activePhysicalId'] |
| ) |
| physical_props_img1 = cam.get_camera_properties_by_id(img1_phy_id) |
| min_focus_distance_img1 = ( |
| physical_props_img1['android.lens.info.minimumFocusDistance'] |
| ) |
| logging.debug('Min focus distance for %s phy_id: %s is %f', |
| lens_suffix1, img1_phy_id, min_focus_distance_img1) |
| logging.debug('Capture results %s crossover: %s', |
| lens_suffix1, capture_result_img1) |
| logging.debug('Capture results %s crossover: %s', |
| lens_suffix2, capture_results[counter-1]) |
| |
| # Get data for last frame where the switch happened |
| img2_file = file_list[counter-1] |
| capture_result_img2 = capture_results[counter-1] |
| logging.debug('Capture results %s crossover converged: %s', |
| lens_suffix2, capture_result_img2) |
| img2_phy_id = ( |
| capture_result_img2['android.logicalMultiCamera.activePhysicalId']) |
| physical_props_img2 = cam.get_camera_properties_by_id(img2_phy_id) |
| min_focus_distance_img2 = ( |
| physical_props_img2['android.lens.info.minimumFocusDistance'] |
| ) |
| logging.debug('Min focus distance for %s phy_id: %s is %f', |
| lens_suffix2, img2_phy_id, min_focus_distance_img2) |
| return img1_file, img2_file, min_focus_distance_img2 |