blob: 5392d1ccb6ff8a55208824d00e7843c3737fdaa8 [file] [log] [blame]
# 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()