| # Copyright 2022 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. |
| |
| import csv |
| import logging |
| import math |
| import os |
| import pickle |
| |
| import camera_properties_utils |
| import capture_request_utils |
| import matplotlib.pyplot as plt |
| from matplotlib.ticker import NullLocator, ScalarFormatter |
| import numpy as np |
| |
| |
| _BAYER_COLOR_PLANE = ('red', 'green_r', 'blue', 'green_b') |
| _LINEAR_FIT_NUM_SAMPLES = 100 # Number of samples to plot for the linear fit |
| _PLOT_AXIS_TICKS = 5 # Number of ticks to display on the plot axis |
| |
| |
| def create_and_save_csv_from_results(rn_data, iso_low, iso_high, cmap, file): |
| """Creates a .csv file for the read noise results. |
| |
| Args: |
| rn_data: Read noise data object from capture_read_noise_for_iso_range |
| iso_low: int; minimum iso value to include |
| iso_high: int; maximum iso value to include |
| cmap: str; string containing each color symbol |
| file: str; path to csv where this will be created |
| """ |
| with open(file, 'w+') as f: |
| writer = csv.writer(f) |
| |
| results = list(filter( |
| lambda x: x[0]['iso'] >= iso_low and x[0]['iso'] <= iso_high, rn_data |
| )) |
| |
| color_channels = range(len(cmap)) |
| |
| # Create headers for csv file |
| headers = ['iso', 'iso^2'] |
| headers.extend([f'mean_{cmap[i]}' for i in color_channels]) |
| headers.extend([f'var_{cmap[i]}' for i in color_channels]) |
| headers.extend([f'norm_var_{cmap[i]}' for i in color_channels]) |
| |
| writer.writerow(headers) |
| |
| # Create data rows |
| for data_row in results: |
| row = [data_row[0]['iso']] |
| row.append(data_row[0]['iso']**2) |
| row.extend([data_row[i]['mean'] for i in color_channels]) |
| row.extend([data_row[i]['var'] for i in color_channels]) |
| row.extend([data_row[i]['norm_var'] for i in color_channels]) |
| |
| writer.writerow(row) |
| |
| writer.writerow([]) # divider line |
| |
| # Create row containing the offset coefficients calculated by np.polyfit |
| coeff_headers = ['', 'offset_coefficient_a', 'offset_coefficient_b'] |
| writer.writerow(coeff_headers) |
| |
| coeff_a, coeff_b = get_read_noise_coefficients(results) |
| for i in range(len(cmap)): |
| writer.writerow([cmap[i], coeff_a[i], coeff_b[i]]) |
| |
| |
| def create_read_noise_plots_from_results(rn_data, iso_low, iso_high, cmap, |
| file): |
| """Plot the read noise data for the given ISO range. |
| |
| Args: |
| rn_data: Read noise data object from capture_read_noise_for_iso_range |
| iso_low: int; minimum iso value to include |
| iso_high: int; maximum iso value to include |
| cmap: str; string containing the Bayer format |
| file: str; file path for the plot image |
| """ |
| # Get a list of color names and plot color arrangements for the given cmap. |
| # This will be used for chart labels and color schemes |
| bayer_color_list = [] |
| plot_colors = '' |
| if cmap.lower() == 'grbg': |
| bayer_color_list = ['GR', 'R', 'B', 'GB'] |
| plot_colors = 'grby' |
| elif cmap.lower() == 'rggb': |
| bayer_color_list = ['R', 'GR', 'GB', 'B'] |
| plot_colors = 'rgyb' |
| elif cmap.lower() == 'bggr': |
| bayer_color_list = ['B', 'GB', 'GR', 'R'] |
| plot_colors = 'bygr' |
| elif cmap.lower() == 'gbrg': |
| bayer_color_list = ['GB', 'B', 'R', 'GR'] |
| plot_colors = 'ybrg' |
| else: |
| raise AssertionError('cmap parameter does not match any known Bayer format') |
| |
| # Create the figure for plotting the read noise to ISO^2 curve |
| fig = plt.figure(figsize=(11, 11)) |
| fig.suptitle('Read Noise to ISO^2', x=0.54, y=0.99) |
| |
| iso_range = fig.add_subplot(111) |
| |
| iso_range.set_xlabel('ISO^2') |
| iso_range.set_ylabel('Read Noise') |
| |
| # Get the ISO values for the current range |
| current_range = list(filter( |
| lambda x: x[0]['iso'] >= iso_low and x[0]['iso'] <= iso_high, rn_data |
| )) |
| |
| # Get X-axis values (ISO^2) for current_range |
| iso_sq = [data[0]['iso']**2 for data in current_range] |
| |
| # Get X-axis values for the calculated linear fit for the read noise |
| iso_sq_values = np.linspace(iso_low**2, iso_high**2, _LINEAR_FIT_NUM_SAMPLES) |
| |
| # Get the line fit coeff for plotting the linear fit of read noise to iso^2 |
| coeff_a, coeff_b = get_read_noise_coefficients(current_range) |
| |
| # Plot the read noise to iso^2 data |
| for pidx in range(len(bayer_color_list)): |
| norm_vars = [data[pidx]['norm_var'] for data in current_range] |
| |
| # Plot the measured read noise to ISO^2 values |
| iso_range.plot(iso_sq, norm_vars, plot_colors[pidx]+'o', |
| label=f'{bayer_color_list[pidx]}', alpha=0.3) |
| |
| # Plot the line fit calculated from the read noise values |
| iso_range.plot(iso_sq_values, coeff_a[pidx]*iso_sq_values + coeff_b[pidx], |
| color=plot_colors[pidx]) |
| |
| # Create a numpy array containing all normalized variance values for the |
| # current range, this will be used for labelling the X-axis |
| y_values = np.array( |
| [[color['norm_var'] for color in x] for x in current_range]) |
| |
| x_ticks = np.linspace(iso_low**2, iso_high**2, _PLOT_AXIS_TICKS) |
| y_ticks = np.linspace(np.min(y_values), np.max(y_values), _PLOT_AXIS_TICKS) |
| |
| iso_range.set_xticks(x_ticks) |
| iso_range.xaxis.set_minor_locator(NullLocator()) |
| iso_range.xaxis.set_major_formatter(ScalarFormatter()) |
| |
| iso_range.set_yticks(y_ticks) |
| iso_range.yaxis.set_minor_locator(NullLocator()) |
| iso_range.yaxis.set_major_formatter(ScalarFormatter()) |
| |
| iso_range.legend() |
| |
| fig.savefig(file) |
| |
| |
| def _generate_image_data_bayer(img, iso, white_level, cmap): |
| """Generates read noise data for a given image. |
| |
| Each element in the list corresponds to each color channel, and each dict |
| contains information relevant to the read noise calculation. |
| |
| Args: |
| img: np.array; image for the given iso |
| iso: float; iso value which the |
| white_level: int; white level value for the sensor |
| cmap: str; color map of the sensor |
| Returns: |
| list(dict) list containing information for each color channel |
| """ |
| result = [] |
| |
| color_channel_img = np.empty((len(_BAYER_COLOR_PLANE), |
| int(img.shape[0]/2), |
| int(img.shape[1]/2))) |
| |
| # Create a dict of read noise values for each color channel in the image |
| for i, color_plane in enumerate(_BAYER_COLOR_PLANE): |
| color_channel_img[i] = _subsample(img, color_plane, cmap) |
| var = np.var(color_channel_img[i]) |
| mean = np.mean(color_channel_img[i]) |
| norm_var = var / ((white_level - mean)**2) |
| result.append({ |
| 'iso': iso, |
| 'mean': mean, |
| 'var': var, |
| 'norm_var': norm_var |
| }) |
| |
| return result |
| |
| |
| def _subsample(img, color_plane, cmap): |
| """Subsample image array based on color_plane. |
| |
| Args: |
| img: 2-D numpy array of image |
| color_plane: string; color to extract |
| cmap: list; color map of the sensor |
| Returns: |
| img_subsample: 2-D numpy subarray of image with only color plane |
| """ |
| subsample_img_2x = lambda img, x, h, v: img[int(x / 2):v:2, x % 2:h:2] |
| size_h = img.shape[1] |
| size_v = img.shape[0] |
| if color_plane == 'red': |
| cmap_index = cmap.index('R') |
| elif color_plane == 'blue': |
| cmap_index = cmap.index('B') |
| elif color_plane == 'green_r': |
| color_plane_map_index = { |
| 'GRBG': 0, |
| 'RGGB': 1, |
| 'BGGR': 2, |
| 'GBRG': 3 |
| } |
| cmap_index = color_plane_map_index[cmap] |
| elif color_plane == 'green_b': |
| color_plane_map_index = { |
| 'GBRG': 0, |
| 'BGGR': 1, |
| 'RGGB': 2, |
| 'GRBG': 3 |
| } |
| cmap_index = color_plane_map_index[cmap] |
| else: |
| logging.error('Wrong color_plane entered!') |
| return None |
| |
| return subsample_img_2x(img, cmap_index, size_h, size_v) |
| |
| |
| def get_read_noise_coefficients(rn_data, iso_low=0, iso_high=1000000): |
| """Calculate the read noise coefficients from the read noise data. |
| |
| Args: |
| rn_data: Read noise data object from capture_read_noise_for_iso_range |
| iso_low: int; minimum iso value to include |
| iso_high: int; maximum iso value to include |
| Returns: |
| (list, list) Offset coefficients for the linear fit to read noise data |
| """ |
| # Filter the values by the given ISO range |
| iso_range = list(filter( |
| lambda x: x[0]['iso'] >= iso_low and x[0]['iso'] <= iso_high, rn_data |
| )) |
| |
| read_noise_coefficients_a = [] |
| read_noise_coefficients_b = [] |
| |
| # Get ISO^2 values used for X-axis in polyfit |
| iso_sq = [data[0]['iso']**2 for data in iso_range] |
| |
| # Find the linear equation coefficients for each color channel |
| for i in range(len(iso_range[0])): |
| norm_var = [data[i]['norm_var'] for data in iso_range] |
| |
| coeffs = np.polyfit(iso_sq, norm_var, 1) |
| |
| read_noise_coefficients_a.append(coeffs[0]) |
| read_noise_coefficients_b.append(coeffs[1]) |
| |
| return read_noise_coefficients_a, read_noise_coefficients_b |
| |
| |
| def capture_read_noise_for_iso_range(cam, low_iso, high_iso, steps, cmap, |
| dest_file): |
| """Captures read noise data at the lowest advertised exposure value. |
| |
| Args: |
| cam: ItsSession; camera for the current ItsSession |
| low_iso: int; lowest iso value in range |
| high_iso: int; highest iso value in range |
| steps: int; steps to take per stop |
| cmap: str; color map of the sensor |
| dest_file: str; path where the results should be saved |
| Returns: |
| list(list(dict)) Read noise results for each frame |
| """ |
| props = cam.get_camera_properties() |
| props = cam.override_with_hidden_physical_camera_props(props) |
| camera_properties_utils.skip_unless( |
| camera_properties_utils.raw16(props) and |
| camera_properties_utils.manual_sensor(props) and |
| camera_properties_utils.read_3a(props) and |
| camera_properties_utils.per_frame_control(props)) |
| |
| min_exposure_ns, _ = props['android.sensor.info.exposureTimeRange'] |
| min_fd = props['android.lens.info.minimumFocusDistance'] |
| white_level = props['android.sensor.info.whiteLevel'] |
| |
| iso = low_iso |
| |
| results = [] |
| |
| # This operation can last a very long time, if it happens to fail halfway |
| # through, this section of code will allow us to pick up where we left off |
| if os.path.exists(dest_file): |
| # If there already exists a results file, retrieve them |
| with open(dest_file, 'rb') as f: |
| results = pickle.load(f) |
| # Set the starting iso to the last iso of results |
| iso = results[-1][0]['iso'] |
| iso *= math.pow(2, 1.0/steps) |
| |
| while int(round(iso)) <= high_iso: |
| iso_int = int(iso) |
| req = capture_request_utils.manual_capture_request(iso_int, min_exposure_ns) |
| req['android.lens.focusDistance'] = min_fd |
| fmt = {'format': 'raw'} |
| cap = cam.do_capture(req, fmt) |
| w = cap['width'] |
| h = cap['height'] |
| |
| img = np.ndarray(shape=(h*w,), dtype='<u2', buffer=cap['data'][0:w*h*2]) |
| img = img.astype(dtype=np.uint16).reshape(h, w) |
| |
| # Add values to results, organized as a dictionary |
| results.append(_generate_image_data_bayer(img, iso, white_level, cmap)) |
| |
| logging.info('iso: %.2f, mean: %.2f, var: %.2f, min: %d, max: %d', iso, |
| np.mean(img), np.var(img), np.min(img), np.max(img)) |
| |
| with open(dest_file, 'wb+') as f: |
| pickle.dump(results, f) |
| |
| iso *= math.pow(2, 1.0/steps) |
| |
| logging.info('Results pickled into file %s', dest_file) |
| |
| return results |