| # Copyright 2014 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 logging |
| import math |
| import os.path |
| import textwrap |
| from matplotlib import pylab |
| import matplotlib.pyplot as plt |
| from mobly import test_runner |
| import numpy as np |
| import scipy.signal |
| import scipy.stats |
| |
| import its_base_test |
| import capture_request_utils |
| import image_processing_utils |
| import its_session_utils |
| |
| |
| _ZOOM_RATIO = 1.0 # Zoom target to be used while running the model |
| _REMOVE_OUTLIERS = False # When True, filters the variance to remove outliers |
| _OUTLIE_MEDIAN_ABS_DEVS = 10 # Defines the number of Median Absolute Deviations |
| # that consitutes acceptable data |
| _BAYER_LIST = ('R', 'GR', 'GB', 'B') |
| _BRACKET_MAX = 8 # Exposure bracketing range in stops |
| _BRACKET_FACTOR = math.pow(2, _BRACKET_MAX) |
| _PLOT_COLORS = 'rygcbm' # Colors used for plotting the data for each exposure. |
| _MAX_SCALE_FUDGE = 1.1 |
| _MAX_SIGNAL_VALUE = 0.25 # Maximum value to allow mean of the tiles to go. |
| _NAME = os.path.basename(__file__).split('.')[0] |
| _RTOL_EXP_GAIN = 0.97 |
| _STEPS_PER_STOP = 3 # How many sensitivities per stop to sample. |
| _TILE_SIZE = 32 # Tile size to compute mean/variance. Large tiles may have |
| # their variance corrupted by low freq image changes. |
| _TILE_CROP_N = 0 # Number of tiles to crop from edge of image. Usually 0. |
| |
| |
| def check_auto_exposure_targets(auto_e, sens_min, sens_max, props): |
| """Checks if AE too bright for highest gain & too dark for lowest gain.""" |
| |
| min_exposure_ns, max_exposure_ns = props[ |
| 'android.sensor.info.exposureTimeRange'] |
| if auto_e < min_exposure_ns*sens_max: |
| raise AssertionError('Scene is too bright to properly expose at highest ' |
| f'sensitivity: {sens_max}') |
| if auto_e*_BRACKET_FACTOR > max_exposure_ns*sens_min: |
| raise AssertionError('Scene is too dark to properly expose at lowest ' |
| f'sensitivity: {sens_min}') |
| |
| |
| def create_noise_model_code(noise_model_a, noise_model_b, |
| noise_model_c, noise_model_d, |
| sens_min, sens_max, digital_gain_cdef, log_path): |
| """Creates the c file for the noise model.""" |
| |
| noise_model_a_array = ','.join([str(i) for i in noise_model_a]) |
| noise_model_b_array = ','.join([str(i) for i in noise_model_b]) |
| noise_model_c_array = ','.join([str(i) for i in noise_model_c]) |
| noise_model_d_array = ','.join([str(i) for i in noise_model_d]) |
| code = textwrap.dedent(f"""\ |
| /* Generated test code to dump a table of data for external validation |
| * of the noise model parameters. |
| */ |
| #include <stdio.h> |
| #include <assert.h> |
| double compute_noise_model_entry_S(int plane, int sens); |
| double compute_noise_model_entry_O(int plane, int sens); |
| int main(void) {{ |
| for (int plane = 0; plane < {len(noise_model_a)}; plane++) {{ |
| for (int sens = {sens_min}; sens <= {sens_max}; sens += 100) {{ |
| double o = compute_noise_model_entry_O(plane, sens); |
| double s = compute_noise_model_entry_S(plane, sens); |
| printf("%d,%d,%lf,%lf\\n", plane, sens, o, s); |
| }} |
| }} |
| return 0; |
| }} |
| |
| /* Generated functions to map a given sensitivity to the O and S noise |
| * model parameters in the DNG noise model. The planes are in |
| * R, Gr, Gb, B order. |
| */ |
| double compute_noise_model_entry_S(int plane, int sens) {{ |
| static double noise_model_A[] = {{ {noise_model_a_array:s} }}; |
| static double noise_model_B[] = {{ {noise_model_b_array:s} }}; |
| double A = noise_model_A[plane]; |
| double B = noise_model_B[plane]; |
| double s = A * sens + B; |
| return s < 0.0 ? 0.0 : s; |
| }} |
| |
| double compute_noise_model_entry_O(int plane, int sens) {{ |
| static double noise_model_C[] = {{ {noise_model_c_array:s} }}; |
| static double noise_model_D[] = {{ {noise_model_d_array:s} }}; |
| double digital_gain = {digital_gain_cdef:s}; |
| double C = noise_model_C[plane]; |
| double D = noise_model_D[plane]; |
| double o = C * sens * sens + D * digital_gain * digital_gain; |
| return o < 0.0 ? 0.0 : o; |
| }} |
| """) |
| text_file = open(os.path.join(log_path, 'noise_model.c'), 'w') |
| text_file.write('%s' % code) |
| text_file.close() |
| |
| # Creates the noise profile C++ file |
| code = textwrap.dedent(f"""\ |
| /* noise_profile.cc |
| Note: gradient_slope --> gradient of API slope parameter |
| offset_slope --> offset of API slope parameter |
| gradient_intercept--> gradient of API intercept parameter |
| offset_intercept --> offset of API intercept parameter |
| Note: SENSOR_NOISE_PROFILE in Android Developers doc uses |
| N(x) = sqrt(Sx + O), where 'S' is 'slope' & 'O' is 'intercept' |
| */ |
| .noise_profile = |
| {{.noise_coefficients_r = {{.gradient_slope = {noise_model_a[0]}, |
| .offset_slope = {noise_model_b[0]}, |
| .gradient_intercept = {noise_model_c[0]}, |
| .offset_intercept = {noise_model_d[0]}}}, |
| .noise_coefficients_gr = {{.gradient_slope = {noise_model_a[1]}, |
| .offset_slope = {noise_model_b[1]}, |
| .gradient_intercept = {noise_model_c[1]}, |
| .offset_intercept = {noise_model_d[1]}}}, |
| .noise_coefficients_gb = {{.gradient_slope = {noise_model_a[2]}, |
| .offset_slope = {noise_model_b[2]}, |
| .gradient_intercept = {noise_model_c[2]}, |
| .offset_intercept = {noise_model_d[2]}}}, |
| .noise_coefficients_b = {{.gradient_slope = {noise_model_a[3]}, |
| .offset_slope = {noise_model_b[3]}, |
| .gradient_intercept = {noise_model_c[3]}, |
| .offset_intercept = {noise_model_d[3]}}}}}, |
| """) |
| text_file = open(os.path.join(log_path, 'noise_profile.cc'), 'w') |
| text_file.write('%s' % code) |
| text_file.close() |
| |
| def outlier_removed_indices(data, deviations=3): |
| """Removes outliers using median absolute deviation and returns indices kept. |
| |
| Args: |
| data: list to remove outliers from |
| deviations: number of deviations from median to keep |
| Returns: |
| keep_indices: The indices of data which should be kept |
| """ |
| std_dev = scipy.stats.median_abs_deviation(data, axis=None, scale=1) |
| med = np.median(data) |
| keep_indices = np.where( |
| np.logical_and(data>med-deviations*std_dev, data<med+deviations*std_dev)) |
| return keep_indices |
| |
| class DngNoiseModel(its_base_test.ItsBaseTest): |
| """Create DNG noise model. |
| |
| Captures RAW images with increasing analog gains to create the model. |
| def requires 'test' in name to actually run. |
| """ |
| |
| def test_dng_noise_model_generation(self): |
| with its_session_utils.ItsSession( |
| device_id=self.dut.serial, |
| camera_id=self.camera_id, |
| hidden_physical_id=self.hidden_physical_id) as cam: |
| props = cam.get_camera_properties() |
| props = cam.override_with_hidden_physical_camera_props(props) |
| log_path = self.log_path |
| name_with_log_path = os.path.join(log_path, _NAME) |
| if self.hidden_physical_id: |
| camera_name = f'{self.camera_id}.{self.hidden_physical_id}' |
| else: |
| camera_name = self.camera_id |
| logging.info('Starting %s for camera %s', _NAME, camera_name) |
| |
| # Get basic properties we need. |
| sens_min, sens_max = props['android.sensor.info.sensitivityRange'] |
| sens_max_analog = props['android.sensor.maxAnalogSensitivity'] |
| sens_max_meas = sens_max_analog |
| white_level = props['android.sensor.info.whiteLevel'] |
| |
| logging.info('Sensitivity range: [%d, %d]', sens_min, sens_max) |
| logging.info('Max analog sensitivity: %d', sens_max_analog) |
| |
| # Do AE to get a rough idea of where we are. |
| iso_ae, exp_ae, _, _, _ = cam.do_3a( |
| get_results=True, do_awb=False, do_af=False) |
| |
| # Underexpose to get more data for low signal levels. |
| auto_e = iso_ae * exp_ae / _BRACKET_FACTOR |
| check_auto_exposure_targets(auto_e, sens_min, sens_max_meas, props) |
| |
| # Focus at zero to intentionally blur the scene as much as possible. |
| f_dist = 0.0 |
| |
| # Start the sensitivities at the minimum. |
| iso = sens_min |
| samples = [[], [], [], []] |
| plots = [] |
| measured_models = [[], [], [], []] |
| color_plane_plots = {} |
| isos = [] |
| while int(round(iso)) <= sens_max_meas: |
| iso_int = int(round(iso)) |
| isos.append(iso_int) |
| logging.info('ISO %d', iso_int) |
| fig, [[plt_r, plt_gr], [plt_gb, plt_b]] = plt.subplots( |
| 2, 2, figsize=(11, 11)) |
| fig.gca() |
| color_plane_plots[iso_int] = [plt_r, plt_gr, plt_gb, plt_b] |
| fig.suptitle('ISO %d' % iso_int, x=0.54, y=0.99) |
| for i, plot in enumerate(color_plane_plots[iso_int]): |
| plot.set_title('%s' % _BAYER_LIST[i]) |
| plot.set_xlabel('Mean signal level') |
| plot.set_ylabel('Variance') |
| |
| samples_s = [[], [], [], []] |
| for b in range(_BRACKET_MAX): |
| # Get the exposure for this sensitivity and exposure time. |
| exposure = int(math.pow(2, b)*auto_e/iso) |
| logging.info('exp %.3fms', round(exposure*1.0E-6, 3)) |
| req = capture_request_utils.manual_capture_request(iso_int, exposure, |
| f_dist) |
| req['android.control.zoomRatio'] = _ZOOM_RATIO |
| fmt_raw = {'format': 'rawStats', |
| 'gridWidth': _TILE_SIZE, |
| 'gridHeight': _TILE_SIZE} |
| cap = cam.do_capture(req, fmt_raw) |
| if self.debug_mode: |
| img = image_processing_utils.convert_capture_to_rgb_image( |
| cap, props=props) |
| image_processing_utils.write_image( |
| img, f'{name_with_log_path}_{iso_int}_{exposure}ns.jpg', True) |
| |
| mean_img, var_img = image_processing_utils.unpack_rawstats_capture( |
| cap) |
| idxs = image_processing_utils.get_canonical_cfa_order(props) |
| raw_stats_size = mean_img.shape |
| means = [mean_img[_TILE_CROP_N:raw_stats_size[0]-_TILE_CROP_N, |
| _TILE_CROP_N:raw_stats_size[1]-_TILE_CROP_N, i] |
| for i in idxs] |
| vars_ = [var_img[_TILE_CROP_N:raw_stats_size[0]-_TILE_CROP_N, |
| _TILE_CROP_N:raw_stats_size[1]-_TILE_CROP_N, i] |
| for i in idxs] |
| if self.debug_mode: |
| logging.info('rawStats image size: %s', str(raw_stats_size)) |
| logging.info('R planes means image size: %s', str(means[0].shape)) |
| logging.info('means min: %.3f, median: %.3f, max: %.3f', |
| np.min(means), np.median(means), np.max(means)) |
| logging.info('vars_ min: %.4f, median: %.4f, max: %.4f', |
| np.min(vars_), np.median(vars_), np.max(vars_)) |
| |
| # If remove outliers is True, we will filter the variance data |
| if _REMOVE_OUTLIERS: |
| means_filtered = [] |
| vars_filtered = [] |
| for pidx in range(len(means)): |
| keep_indices = outlier_removed_indices(vars_[pidx], |
| _OUTLIE_MEDIAN_ABS_DEVS) |
| means_filtered.append(means[pidx][keep_indices]) |
| vars_filtered.append(vars_[pidx][keep_indices]) |
| |
| means = means_filtered |
| vars_ = vars_filtered |
| |
| s_read = cap['metadata']['android.sensor.sensitivity'] |
| if not 1.0 >= s_read/float(iso_int) >= _RTOL_EXP_GAIN: |
| raise AssertionError( |
| f's_write: {iso}, s_read: {s_read}, RTOL: {_RTOL_EXP_GAIN}') |
| logging.info('ISO_write: %d, ISO_read: %d', iso_int, s_read) |
| |
| for pidx in range(len(means)): |
| plot = color_plane_plots[iso_int][pidx] |
| |
| # convert_capture_to_planes normalizes the range to [0, 1], but |
| # without subtracting the black level. |
| black_level = image_processing_utils.get_black_level( |
| pidx, props, cap['metadata']) |
| means_p = (means[pidx] - black_level)/(white_level - black_level) |
| vars_p = vars_[pidx]/((white_level - black_level)**2) |
| |
| # TODO(dsharlet): It should be possible to account for low |
| # frequency variation by looking at neighboring means, but I |
| # have not been able to make this work. |
| |
| means_p = np.asarray(means_p).flatten() |
| vars_p = np.asarray(vars_p).flatten() |
| |
| samples_e = [] |
| for (mean, var) in zip(means_p, vars_p): |
| # Don't include the tile if it has samples that might be clipped. |
| if mean + 2*math.sqrt(max(var, 0)) < _MAX_SIGNAL_VALUE: |
| samples_e.append([mean, var]) |
| |
| if samples_e: |
| means_e, vars_e = zip(*samples_e) |
| color_plane_plots[iso_int][pidx].plot( |
| means_e, vars_e, _PLOT_COLORS[b%len(_PLOT_COLORS)] + '.', |
| alpha=0.5, markersize=1) |
| samples_s[pidx].extend(samples_e) |
| |
| for (pidx, p) in enumerate(samples_s): |
| [slope, intercept, rvalue, _, _] = scipy.stats.linregress( |
| samples_s[pidx]) |
| measured_models[pidx].append([iso_int, slope, intercept]) |
| logging.info('%s sensitivity %d: %e*y + %e (R=%f)', |
| 'RGKB'[pidx], iso_int, slope, intercept, rvalue) |
| |
| # Add the samples for this sensitivity to the global samples list. |
| samples[pidx].extend( |
| [(iso_int, mean, var) for (mean, var) in samples_s[pidx]]) |
| |
| # Add the linear fit to subplot for this sensitivity. |
| color_plane_plots[iso_int][pidx].plot( |
| [0, _MAX_SIGNAL_VALUE], |
| [intercept, intercept + slope * _MAX_SIGNAL_VALUE], |
| 'rgkb'[pidx] + '--', |
| label='Linear fit') |
| |
| xmax = max([max([x for (x, _) in p]) for p in samples_s |
| ]) * _MAX_SCALE_FUDGE |
| ymax = (intercept + slope * xmax) * _MAX_SCALE_FUDGE |
| color_plane_plots[iso_int][pidx].set_xlim(xmin=0, xmax=xmax) |
| color_plane_plots[iso_int][pidx].set_ylim(ymin=0, ymax=ymax) |
| color_plane_plots[iso_int][pidx].legend() |
| pylab.tight_layout() |
| |
| fig.savefig(f'{name_with_log_path}_samples_iso{iso_int:04d}.png') |
| plots.append([iso_int, fig]) |
| |
| # Move to the next sensitivity. |
| iso *= math.pow(2, 1.0/_STEPS_PER_STOP) |
| |
| # Do model plots |
| (fig, (plt_s, plt_o)) = plt.subplots(2, 1, figsize=(11, 8.5)) |
| plt_s.set_title('Noise model: N(x) = sqrt(Sx + O)') |
| plt_s.set_ylabel('S') |
| plt_o.set_xlabel('ISO') |
| plt_o.set_ylabel('O') |
| |
| noise_model = [] |
| for (pidx, p) in enumerate(measured_models): |
| # Grab the sensitivities and line parameters from each sensitivity. |
| s_measured = [e[1] for e in measured_models[pidx]] |
| o_measured = [e[2] for e in measured_models[pidx]] |
| sens = np.asarray([e[0] for e in measured_models[pidx]]) |
| sens_sq = np.square(sens) |
| |
| # Use a global linear optimization to fit the noise model. |
| gains = np.asarray([s[0] for s in samples[pidx]]) |
| means = np.asarray([s[1] for s in samples[pidx]]) |
| vars_ = np.asarray([s[2] for s in samples[pidx]]) |
| gains = gains.flatten() |
| means = means.flatten() |
| vars_ = vars_.flatten() |
| |
| # Define digital gain as the gain above the max analog gain |
| # per the Camera2 spec. Also, define a corresponding C |
| # expression snippet to use in the generated model code. |
| digital_gains = np.maximum(gains/sens_max_analog, 1) |
| if not np.all(digital_gains == 1): |
| raise AssertionError(f'Digital gain! gains: {gains}, ' |
| f'Max analog gain: {sens_max_analog}.') |
| digital_gain_cdef = '(sens / %d.0) < 1.0 ? 1.0 : (sens / %d.0)' % ( |
| sens_max_analog, sens_max_analog) |
| |
| # Divide the whole system by gains*means. |
| f = lambda x, a, b, c, d: (c*(x[0]**2)+d+(x[1])*a*x[0]+(x[1])*b)/(x[0]) |
| result, _ = scipy.optimize.curve_fit(f, (gains, means), vars_/(gains)) |
| # result[0:4] = s_gradient, s_offset, o_gradient, o_offset |
| # Note 'S' and 'O' are the API terms for the 2 model params. |
| # The noise_profile.cc uses 'slope' for 'S' and 'intercept' for 'O'. |
| # 'gradient' and 'offset' are used to describe the linear fit |
| # parameters for 'S' and 'O'. |
| noise_model.append(result[0:4]) |
| |
| # Plot noise model components with the values predicted by the model. |
| s_model = result[0]*sens + result[1] |
| o_model = result[2]*sens_sq + result[3]*np.square(np.maximum( |
| sens/sens_max_analog, 1)) |
| |
| plt_s.loglog(sens, s_measured, 'rgkb'[pidx]+'+', base=10, |
| label='Measured') |
| plt_s.loglog(sens, s_model, 'rgkb'[pidx]+'o', base=10, |
| label='Model', alpha=0.3) |
| plt_o.loglog(sens, o_measured, 'rgkb'[pidx]+'+', base=10, |
| label='Measured') |
| plt_o.loglog(sens, o_model, 'rgkb'[pidx]+'o', base=10, |
| label='Model', alpha=0.3) |
| plt_s.legend() |
| plt_s.set_xticks(isos) |
| plt_s.set_xticklabels(isos) |
| |
| plt_o.set_xticks([]) |
| plt_o.set_xticks(isos) |
| plt_o.set_xticklabels(isos) |
| plt_o.legend() |
| fig.savefig(f'{name_with_log_path}.png') |
| |
| # Generate individual noise model components |
| noise_model_a, noise_model_b, noise_model_c, noise_model_d = zip( |
| *noise_model) |
| |
| # Add models to subplots and re-save |
| for [iso, fig] in plots: # re-step through figs... |
| dig_gain = max(iso/sens_max_analog, 1) |
| fig.gca() |
| for (pidx, p) in enumerate(measured_models): |
| s = noise_model_a[pidx]*iso + noise_model_b[pidx] |
| o = noise_model_c[pidx]*iso**2 + noise_model_d[pidx]*dig_gain**2 |
| color_plane_plots[iso][pidx].plot( |
| [0, _MAX_SIGNAL_VALUE], [o, o+s*_MAX_SIGNAL_VALUE], |
| 'rgkb'[pidx]+'-', label='Model', alpha=0.5) |
| color_plane_plots[iso][pidx].legend(loc='upper left') |
| fig.savefig(f'{name_with_log_path}_samples_iso{iso:04d}.png') |
| |
| # Validity checks on model: read noise > 0, positive intercept gradient. |
| for i, _ in enumerate(_BAYER_LIST): |
| read_noise = noise_model_c[i] * sens_min * sens_min + noise_model_d[i] |
| if read_noise <= 0: |
| raise AssertionError(f'{_BAYER_LIST[i]} model min ISO noise < 0! ' |
| f'API intercept gradient: {noise_model_c[i]:.4e}, ' |
| f'API intercept offset: {noise_model_d[i]:.4e}, ' |
| f'read_noise: {read_noise:.4e}') |
| if noise_model_c[i] <= 0: |
| raise AssertionError(f'{_BAYER_LIST[i]} model API intercept gradient ' |
| f'is negative: {noise_model_c[i]:.4e}') |
| |
| # Generate the noise model file. |
| create_noise_model_code( |
| noise_model_a, noise_model_b, noise_model_c, noise_model_d, |
| sens_min, sens_max, digital_gain_cdef, log_path) |
| |
| if __name__ == '__main__': |
| test_runner.main() |