# 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. """Utility functions to enable capture read noise analysis.""" import csv import logging import math import os import pickle import matplotlib.pyplot as plt from matplotlib.ticker import NullLocator from matplotlib.ticker import ScalarFormatter import numpy as np import camera_properties_utils import capture_request_utils _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='