| # 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 Default camera app and JCA image Parity metrics.""" |
| |
| import logging |
| import math |
| |
| import cv2 |
| import numpy as np |
| |
| _DYNAMIC_PATCH_MID_TONE_START_IDX = 5 |
| _DYNAMIC_PATCH_MID_TONE_END_IDX = 15 |
| AR_REL_TOL = 0.1 |
| EXPECTED_BRIGHTNESS_50 = 50.0 |
| MAX_BRIGHTNESS_DIFF_ABSOLUTE_ERROR = 10.0 |
| MAX_BRIGHTNESS_DIFF_RELATIVE_ERROR = 8.0 |
| MAX_DELTA_AB_WHITE_BALANCE_ABSOLUTE_ERROR = 6.0 |
| MAX_DELTA_AB_WHITE_BALANCE_RELATIVE_ERROR = 3.0 |
| # This is the height of center QR code on feature chart in cm |
| CENTER_QR_CODE_CM = 5 |
| FOV_REL_TOL = 0.1 |
| |
| |
| def check_if_qr_code_size_match(img1, img2): |
| """Checks if the size of two images are the same or not. |
| |
| Args: |
| img1: first image array in BGRA format |
| img2: second image array in BGRA format |
| Returns: |
| True if the size of two images are the same, False otherwise |
| """ |
| # Extract the alpha channel |
| alpha_channel_1 = img1[:, :, 3] |
| alpha_channel_2 = img2[:, :, 3] |
| |
| # Find the non-zero (non-transparent) pixels |
| y1_indices, x1_indices = np.where(alpha_channel_1 != 0) |
| y2_indices, x2_indices = np.where(alpha_channel_2 != 0) |
| |
| # Get the bounding box of the non-transparent region |
| min_x1 = np.min(x1_indices) |
| min_y1 = np.min(y1_indices) |
| max_x1 = np.max(x1_indices) |
| max_y1 = np.max(y1_indices) |
| |
| min_x2 = np.min(x2_indices) |
| min_y2 = np.min(y2_indices) |
| max_x2 = np.max(x2_indices) |
| max_y2 = np.max(y2_indices) |
| |
| # Crop the image to the bounding box |
| non_tranpsarent_patch_1 = img1[min_y1:max_y1 + 1, min_x1:max_x1 + 1] |
| non_tranpsarent_patch_2 = img2[min_y2:max_y2 + 1, min_x2:max_x2 + 1] |
| |
| height1, width1 = non_tranpsarent_patch_1.shape[:2] |
| logging.debug('Height 1: %s, Width 1: %s', height1, width1) |
| ar_1 = width1 / height1 |
| logging.debug('Aspect ratio 1: %.2f', ar_1) |
| if not math.isclose(ar_1, 1, rel_tol=AR_REL_TOL): |
| raise ValueError( |
| 'Aspect ratio of the non-transparent region of the image 1 is not 1:1.' |
| ) |
| height2, width2 = non_tranpsarent_patch_2.shape[:2] |
| logging.debug('Height 2: %s, Width 2: %s', height2, width2) |
| ar_2 = width2 / height2 |
| logging.debug('Aspect ratio 2: %.2f', ar_2) |
| if not math.isclose(ar_2, 1, rel_tol=AR_REL_TOL): |
| raise ValueError( |
| 'Aspect ratio of the non-transparent region of the image 2 is not 1:1.' |
| ) |
| return math.isclose(height1, height2, rel_tol=AR_REL_TOL) |
| |
| |
| def get_lab_mean_values(img): |
| """Computes the mean values of the 'L', 'A', and 'B' channels. |
| |
| Converts the img from RGB to CIELAB color space and calculates the mean values |
| of L, A and B channels only for the non-transparent regions of the image |
| |
| Args: |
| img: img array in RGB colorspace. |
| Returns: |
| mean_l, mean_a, mean_b: mean value of l, a, b channels |
| """ |
| img_lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB) |
| img_lab = img_lab.astype(np.uint32) |
| mean_l = np.mean(img_lab[:, :, 0]) * 100 / 255 |
| mean_a = np.mean(img_lab[:, :, 1]) - 128 |
| mean_b = np.mean(img_lab[:, :, 2]) - 128 |
| logging.debug('L, A, B values: %.2f %.2f %.2f', mean_l, mean_a, mean_b) |
| return mean_l, mean_a, mean_b |
| |
| |
| def get_brightness_variation( |
| default_brightness_values, jca_brightness_values |
| ): |
| """Gets the brightness variation between default and jca color cells. |
| |
| Args: |
| default_brightness_values: The default brightness values of the greyscale |
| cells |
| jca_brightness_values: The jca brightness values of the greyscale cells |
| |
| Returns: |
| mean_delta_ab_diff: mean delta ab diff between default and jca rounded |
| upto 2 places |
| """ |
| default_brightness = np.mean(default_brightness_values) |
| jca_brightness = np.mean(jca_brightness_values) |
| |
| default_ref_brightness_diff = default_brightness - EXPECTED_BRIGHTNESS_50 |
| jca_ref_brightness_diff = jca_brightness - EXPECTED_BRIGHTNESS_50 |
| default_jca_brightness_diff = jca_brightness - default_brightness |
| logging.debug('default_ref_brightness_diff: %.2f', |
| default_ref_brightness_diff) |
| logging.debug('jca_ref_brightness_diff: %.2f', |
| jca_ref_brightness_diff) |
| logging.debug('default_jca_brightness_diff: %.2f', |
| default_jca_brightness_diff) |
| |
| # Check that the brightness difference default and jca to the reference do not |
| # exceed the max absolute error |
| if (default_ref_brightness_diff > MAX_BRIGHTNESS_DIFF_ABSOLUTE_ERROR) or ( |
| jca_ref_brightness_diff > MAX_BRIGHTNESS_DIFF_ABSOLUTE_ERROR |
| ): |
| e_msg = ( |
| f'The brightness of default and jca for greyscale cells exceeds the' |
| f' threshold. Actual default: {default_ref_brightness_diff:.2f}, Actual' |
| f' jca: {default_jca_brightness_diff:.2f}, Expected:' |
| f' {MAX_BRIGHTNESS_DIFF_ABSOLUTE_ERROR:.1f}' |
| ) |
| logging.debug(e_msg) |
| # Check that the brightness between default and jca does not exceed the |
| # max relative error |
| if (default_jca_brightness_diff > MAX_BRIGHTNESS_DIFF_RELATIVE_ERROR): |
| e_msg = ( |
| f'The brightness difference between default and jca for greyscale cells' |
| f' exceeds the threshold. Actual: {default_jca_brightness_diff:.2f}, ' |
| f'Expected: {MAX_BRIGHTNESS_DIFF_RELATIVE_ERROR:.1f}' |
| ) |
| logging.debug(e_msg) |
| return default_jca_brightness_diff |
| |
| |
| def do_brightness_check(default_patch_list, jca_patch_list): |
| """Computes brightness diff between default and jca capture images. |
| |
| Args: |
| default_patch_list: default camera dynamic range patch cells |
| jca_patch_list: jca camera dynamic range patch cells |
| |
| Returns: |
| mean_brightness_diff: mean brightness diff between default and jca |
| """ |
| default_brightness_values = [] |
| for patch in default_patch_list: |
| mean_l, _, _ = get_lab_mean_values(patch) |
| default_brightness_values.append(mean_l) |
| jca_brightness_values = [] |
| for patch in jca_patch_list: |
| mean_l, _, _ = get_lab_mean_values(patch) |
| jca_brightness_values.append(mean_l) |
| |
| default_rounded_values = [round(float(x), 2) |
| for x in default_brightness_values] |
| jca_rounded_values = [round(float(x), 2) for x in jca_brightness_values] |
| |
| logging.debug('default_brightness_values: %s', default_rounded_values) |
| logging.debug('jca_brightness_values: %s', jca_rounded_values) |
| |
| mean_brightness_diff = get_brightness_variation( |
| default_brightness_values[ |
| _DYNAMIC_PATCH_MID_TONE_START_IDX:_DYNAMIC_PATCH_MID_TONE_END_IDX |
| ], |
| jca_brightness_values[ |
| _DYNAMIC_PATCH_MID_TONE_START_IDX:_DYNAMIC_PATCH_MID_TONE_END_IDX |
| ], |
| ) |
| logging.debug( |
| 'Brightness difference between default and jca: %.2f', |
| mean_brightness_diff, |
| ) |
| return round(float(mean_brightness_diff), 2) |
| |
| |
| def get_neutral_delta_ab(greyscale_cells): |
| """Returns the delta ab value for grey scale cells compared to reference. |
| |
| Args: |
| greyscale_cells: list of grey scale cells |
| |
| Returns: |
| neutral_delta_ab_values: list of neutral delta ab values for each color cell |
| """ |
| neutral_delta_ab_values = [] |
| for i, greyscale_cell in enumerate(greyscale_cells): |
| _, mean_a, mean_b = get_lab_mean_values(greyscale_cell) |
| neutral_delta_ab = np.sqrt(mean_a**2 + mean_b**2) |
| logging.debug( |
| 'Reference delta AB value for greyscale cell %d: %.2f', |
| i + 1, |
| neutral_delta_ab, |
| ) |
| neutral_delta_ab_values.append(neutral_delta_ab) |
| return neutral_delta_ab_values |
| |
| |
| def get_delta_ab(color_cells_1, color_cells_2): |
| """Computes the delta ab value between two color cells. |
| |
| Args: |
| color_cells_1: first color cells array |
| color_cells_2: second color cells array |
| |
| Returns: |
| delta_ab_values: list of delta ab values for each color cell |
| """ |
| delta_ab_values = [] |
| for i, (color_cell_1, color_cell_2) in enumerate( |
| zip(color_cells_1, color_cells_2) |
| ): |
| _, mean_a_1, mean_b_1 = get_lab_mean_values(color_cell_1) |
| _, mean_a_2, mean_b_2 = get_lab_mean_values(color_cell_2) |
| delta_ab = np.sqrt((mean_a_1 - mean_a_2) ** 2 + (mean_b_1 - mean_b_2) ** 2) |
| logging.debug('Delta AB value for color cell %d: %.2f', i + 1, delta_ab) |
| delta_ab_values.append(delta_ab) |
| return delta_ab_values |
| |
| |
| def get_white_balance_variation( |
| default_greyscale_cells, jca_greyscale_cells |
| ): |
| """Gets the white balance variation between default and jca color cells. |
| |
| Args: |
| default_greyscale_cells: list of default greyscale cells |
| jca_greyscale_cells: list of jca greyscale cells |
| |
| Returns: |
| mean_delta_ab_diff: mean delta ab diff between default and jca |
| """ |
| default_neutral_delta_ab = np.mean( |
| get_neutral_delta_ab(default_greyscale_cells) |
| ) |
| jca_neutral_delta_ab = np.mean(get_neutral_delta_ab(jca_greyscale_cells)) |
| default_jca_neutral_delta_ab = np.mean( |
| get_delta_ab(default_greyscale_cells, jca_greyscale_cells) |
| ) |
| logging.debug('default_neutral_delta_ab_rounded_values: %.2f', |
| default_neutral_delta_ab) |
| logging.debug('jca_neutral_delta_ab_rounded_values: %.2f', |
| jca_neutral_delta_ab) |
| logging.debug('default_jca_neutral_delta_ab_rounded_values: %.2f', |
| default_jca_neutral_delta_ab) |
| |
| # Check that the white balance between default and jca does not exceed the |
| # max absolute error. |
| if (default_neutral_delta_ab > MAX_DELTA_AB_WHITE_BALANCE_ABSOLUTE_ERROR) or ( |
| jca_neutral_delta_ab > MAX_DELTA_AB_WHITE_BALANCE_ABSOLUTE_ERROR |
| ): |
| e_msg = ( |
| f'White balance of default and jca images exceeds the threshold.' |
| f'Actual default value: {default_neutral_delta_ab:.2f},' |
| f'Actual jca value: {jca_neutral_delta_ab:.2f}, ' |
| f'Expected maximum: {MAX_DELTA_AB_WHITE_BALANCE_ABSOLUTE_ERROR:.1f}' |
| ) |
| logging.debug(e_msg) |
| # Check that the white balance between default and jca does not exceed the |
| # max relative error. |
| if (default_jca_neutral_delta_ab > MAX_DELTA_AB_WHITE_BALANCE_RELATIVE_ERROR): |
| e_msg = ( |
| f'White balance between default and jca for greyscale cells exceeds the' |
| f' threshold. Actual default: {default_jca_neutral_delta_ab:.2f}, ' |
| f'Expected: {MAX_DELTA_AB_WHITE_BALANCE_RELATIVE_ERROR:.1f}' |
| ) |
| logging.debug(e_msg) |
| return default_jca_neutral_delta_ab |
| |
| |
| def do_white_balance_check(default_patch_list, jca_patch_list): |
| """Computes white balance diff between default and jca images. |
| |
| Args: |
| default_patch_list: default camera dynamic range patch cells |
| jca_patch_list: jca camera dynamic range patch cells |
| |
| Returns: |
| mean_neutral_delta_ab: mean neutral delta ab between default and jca |
| rounded to 2 places |
| """ |
| default_a_values = [] |
| default_b_values = [] |
| default_middle_tone_patch_list = default_patch_list[ |
| _DYNAMIC_PATCH_MID_TONE_START_IDX:_DYNAMIC_PATCH_MID_TONE_END_IDX |
| ] |
| for patch in default_middle_tone_patch_list: |
| _, mean_a, mean_b = get_lab_mean_values(patch) |
| default_a_values.append(mean_a) |
| default_b_values.append(mean_b) |
| jca_a_values = [] |
| jca_b_values = [] |
| jca_middle_tone_patch_list = jca_patch_list[ |
| _DYNAMIC_PATCH_MID_TONE_START_IDX:_DYNAMIC_PATCH_MID_TONE_END_IDX |
| ] |
| for patch in jca_middle_tone_patch_list: |
| _, mean_a, mean_b = get_lab_mean_values(patch) |
| jca_a_values.append(mean_a) |
| jca_b_values.append(mean_b) |
| |
| default_rounded_a_values = [round(float(x), 2) |
| for x in default_a_values] |
| default_rounded_b_values = [round(float(x), 2) |
| for x in default_b_values] |
| jca_rounded_a_values = [round(float(x), 2) |
| for x in jca_a_values] |
| jca_rounded_b_values = [round(float(x), 2) |
| for x in jca_b_values] |
| logging.debug('default_rounded_a_values: %s', default_rounded_a_values) |
| logging.debug('default_rounded_b_values: %s', default_rounded_b_values) |
| logging.debug('jca_rounded_a_values: %s', jca_rounded_a_values) |
| logging.debug('jca_rounded_b_values: %s', jca_rounded_b_values) |
| |
| mean_neutral_delta_ab = get_white_balance_variation( |
| default_middle_tone_patch_list, |
| jca_middle_tone_patch_list, |
| ) |
| logging.debug( |
| 'White balance difference between default and jca: %.2f', |
| mean_neutral_delta_ab, |
| ) |
| return round(float(mean_neutral_delta_ab), 2) |
| |
| |
| def _get_non_transparent_pixels(img): |
| """Returns the non transparent pixels from BGRA image. |
| """ |
| alpha_channel = img[:, :, 3] |
| |
| # Find the non-zero (non-transparent) pixels |
| y_indices, x_indices = np.where(alpha_channel != 0) |
| |
| # Get the bounding box of the non-transparent region |
| min_x = np.min(x_indices) |
| min_y = np.min(y_indices) |
| max_x = np.max(x_indices) |
| max_y = np.max(y_indices) |
| |
| # Crop the image to the bounding box |
| non_tranpsarent_patch = img[min_y:max_y + 1, min_x:max_x + 1] |
| return non_tranpsarent_patch |
| |
| |
| def get_fov_in_degrees(img_path, qr_code_img, chart_distance): |
| """Returns fov measurement in degrees. |
| |
| Args: |
| img_path: captured img path |
| qr_code_img: Extracted center QR code img |
| chart_distance: distance between phone and chart in cm |
| Returns: |
| fov_degrees: FoV measurement in degrees |
| """ |
| img = cv2.imread(img_path) |
| img_height, _ = img.shape[:2] |
| logging.debug('Height of captured img in pixels: %d', img_height) |
| |
| nt_qr_code_img = _get_non_transparent_pixels(qr_code_img) |
| qr_code_height, _ = nt_qr_code_img.shape[:2] |
| logging.debug('Height of QR code in pixels: %d', qr_code_height) |
| |
| # Get captured image height in cm |
| height_in_cm = (img_height / qr_code_height) * CENTER_QR_CODE_CM |
| logging.debug('Height of captured img in cm: %d', height_in_cm) |
| angle_radians = 2 * math.atan(height_in_cm / (2 * chart_distance)) |
| fov_degrees = math.degrees(angle_radians) |
| return fov_degrees |