1# Copyright 2013 The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14"""Verifies correct exposure control.""" 15 16 17import logging 18import os.path 19 20from matplotlib import pyplot as plt 21from mobly import test_runner 22import numpy as np 23 24import its_base_test 25import camera_properties_utils 26import capture_request_utils 27import image_processing_utils 28import its_session_utils 29import target_exposure_utils 30 31_EXP_CORRECTION_FACTOR = 2 # mult or div factor to correct brightness 32_NAME = os.path.splitext(os.path.basename(__file__))[0] 33_NUM_PTS_2X_GAIN = 3 # 3 points every 2x increase in gain 34_PATCH_H = 0.1 # center 10% patch params 35_PATCH_W = 0.1 36_PATCH_X = 0.5 - _PATCH_W/2 37_PATCH_Y = 0.5 - _PATCH_H/2 38_RAW_STATS_GRID = 9 # define 9x9 (11.11%) spacing grid for rawStats processing 39_RAW_STATS_XY = _RAW_STATS_GRID//2 # define X, Y location for center rawStats 40_THRESH_MIN_LEVEL = 0.1 41_THRESH_MAX_LEVEL = 0.9 42_THRESH_MAX_LEVEL_DIFF = 0.045 43_THRESH_MAX_LEVEL_DIFF_WIDE_RANGE = 0.06 44_THRESH_MAX_OUTLIER_DIFF = 0.1 45_THRESH_ROUND_DOWN_ISO = 0.04 46_THRESH_ROUND_DOWN_EXP = 0.03 47_THRESH_ROUND_DOWN_EXP0 = 1.00 # RTOL @0ms exp; theoretical limit @ 4-line exp 48_THRESH_EXP_KNEE = 6E6 # exposures less than knee have relaxed tol 49_WIDE_EXP_RANGE_THRESH = 64.0 # threshold for 'wide' range sensor 50 51 52def adjust_exp_for_brightness( 53 cam, props, fmt, exp, iso, sync_latency, test_name_with_path): 54 """Take an image and adjust exposure and sensitivity. 55 56 Args: 57 cam: camera object 58 props: camera properties dict 59 fmt: capture format 60 exp: exposure time (ns) 61 iso: sensitivity 62 sync_latency: number for sync latency 63 test_name_with_path: path for saved files 64 65 Returns: 66 adjusted exposure 67 """ 68 req = capture_request_utils.manual_capture_request( 69 iso, exp, 0.0, True, props) 70 cap = its_session_utils.do_capture_with_latency( 71 cam, req, sync_latency, fmt) 72 img = image_processing_utils.convert_capture_to_rgb_image(cap) 73 image_processing_utils.write_image( 74 img, f'{test_name_with_path}.jpg') 75 patch = image_processing_utils.get_image_patch( 76 img, _PATCH_X, _PATCH_Y, _PATCH_W, _PATCH_H) 77 r, g, b = image_processing_utils.compute_image_means(patch) 78 logging.debug('Sample RGB values: %.3f, %.3f, %.3f', r, g, b) 79 if g < _THRESH_MIN_LEVEL: 80 exp *= _EXP_CORRECTION_FACTOR 81 logging.debug('exp increased by %dx: %d', _EXP_CORRECTION_FACTOR, exp) 82 elif g > _THRESH_MAX_LEVEL: 83 exp //= _EXP_CORRECTION_FACTOR 84 logging.debug('exp decreased to 1/%dx: %d', _EXP_CORRECTION_FACTOR, exp) 85 return exp 86 87 88def plot_rgb_means(title, x, r, g, b, test_name_with_path): 89 """Plot the RGB mean data. 90 91 Args: 92 title: string for figure title 93 x: x values for plot, gain multiplier 94 r: r plane means 95 g: g plane means 96 b: b plane menas 97 test_name_with_path: path for saved files 98 """ 99 plt.figure(title) 100 plt.semilogx(x, r, 'ro-') 101 plt.semilogx(x, g, 'go-') 102 plt.semilogx(x, b, 'bo-') 103 plt.title(f'{_NAME} {title}') 104 plt.xlabel('Gain Multiplier') 105 plt.ylabel('Normalized RGB Plane Avg') 106 plt.minorticks_off() 107 plt.xticks(x[0::_NUM_PTS_2X_GAIN], x[0::_NUM_PTS_2X_GAIN]) 108 plt.ylim([0, 1]) 109 plt.savefig(f'{test_name_with_path}_plot_rgb_means.png') 110 111 112def plot_raw_means(title, x, r, gr, gb, b, test_name_with_path): 113 """Plot the RAW mean data. 114 115 Args: 116 title: string for figure title 117 x: x values for plot, gain multiplier 118 r: R plane means 119 gr: Gr plane means 120 gb: Gb plane means 121 b: B plane menas 122 test_name_with_path: path for saved files 123 """ 124 plt.figure(title) 125 plt.semilogx(x, r, 'ro-', label='R') 126 plt.semilogx(x, gr, 'go-', label='Gr') 127 plt.semilogx(x, gb, 'ko-', label='Gb') 128 plt.semilogx(x, b, 'bo-', label='B') 129 plt.title(f'{_NAME} {title}') 130 plt.xlabel('Gain Multiplier') 131 plt.ylabel('Normalized RAW Plane Avg') 132 plt.minorticks_off() 133 plt.xticks(x[0::_NUM_PTS_2X_GAIN], x[0::_NUM_PTS_2X_GAIN]) 134 plt.ylim([0, 1]) 135 plt.legend(numpoints=1) 136 plot_name = f'{test_name_with_path}_plot_raw_means.png' 137 plt.savefig(plot_name) 138 139 140def check_line_fit(color, mults, values, thresh_max_level_diff): 141 """Find line fit and check values. 142 143 Check for linearity. Verify sample pixel mean values are close to each 144 other. Also ensure that the images aren't clamped to 0 or 1 145 (which would also make them look like flat lines). 146 147 Args: 148 color: string to define RGB or RAW channel 149 mults: list of multiplication values for gain*m, exp/m 150 values: mean values for chan 151 thresh_max_level_diff: threshold for max difference 152 """ 153 154 m, b = np.polyfit(mults, values, 1).tolist() 155 min_val = min(values) 156 max_val = max(values) 157 max_diff = max_val - min_val 158 logging.debug('Channel %s line fit (y = mx+b): m = %f, b = %f', color, m, b) 159 logging.debug('Channel min %f max %f diff %f', min_val, max_val, max_diff) 160 if max_diff >= thresh_max_level_diff: 161 raise AssertionError(f'max_diff: {max_diff:.4f}, ' 162 f'THRESH: {thresh_max_level_diff:.3f}') 163 if not _THRESH_MAX_LEVEL > b > _THRESH_MIN_LEVEL: 164 raise AssertionError(f'b: {b:.2f}, THRESH_MIN: {_THRESH_MIN_LEVEL}, ' 165 f'THRESH_MAX: {_THRESH_MAX_LEVEL}') 166 for v in values: 167 if not _THRESH_MAX_LEVEL > v > _THRESH_MIN_LEVEL: 168 raise AssertionError(f'v: {v:.2f}, THRESH_MIN: {_THRESH_MIN_LEVEL}, ' 169 f'THRESH_MAX: {_THRESH_MAX_LEVEL}') 170 171 if abs(v - b) >= _THRESH_MAX_OUTLIER_DIFF: 172 raise AssertionError(f'v: {v:.2f}, b: {b:.2f}, ' 173 f'THRESH_DIFF: {_THRESH_MAX_OUTLIER_DIFF}') 174 175 176def get_raw_active_array_size(props): 177 """Return the active array w, h from props.""" 178 aaw = (props['android.sensor.info.preCorrectionActiveArraySize']['right'] - 179 props['android.sensor.info.preCorrectionActiveArraySize']['left']) 180 aah = (props['android.sensor.info.preCorrectionActiveArraySize']['bottom'] - 181 props['android.sensor.info.preCorrectionActiveArraySize']['top']) 182 return aaw, aah 183 184 185class ExposureXIsoTest(its_base_test.ItsBaseTest): 186 """Test that a constant brightness is seen as ISO and exposure time vary. 187 188 Take a series of shots that have ISO and exposure time chosen to balance 189 each other; result should be the same brightness, but over the sequence 190 the images should get noisier. 191 """ 192 193 def test_exposure_x_iso(self): 194 mults = [] 195 r_means = [] 196 g_means = [] 197 b_means = [] 198 raw_r_means = [] 199 raw_gr_means = [] 200 raw_gb_means = [] 201 raw_b_means = [] 202 thresh_max_level_diff = _THRESH_MAX_LEVEL_DIFF 203 204 with its_session_utils.ItsSession( 205 device_id=self.dut.serial, 206 camera_id=self.camera_id, 207 hidden_physical_id=self.hidden_physical_id) as cam: 208 props = cam.get_camera_properties() 209 props = cam.override_with_hidden_physical_camera_props(props) 210 test_name_with_path = os.path.join(self.log_path, _NAME) 211 212 # Check SKIP conditions 213 camera_properties_utils.skip_unless( 214 camera_properties_utils.compute_target_exposure(props)) 215 216 # Load chart for scene 217 its_session_utils.load_scene( 218 cam, props, self.scene, self.tablet, 219 its_session_utils.CHART_DISTANCE_NO_SCALING) 220 221 # Initialize params for requests 222 debug = self.debug_mode 223 raw_avlb = (camera_properties_utils.raw16(props) and 224 camera_properties_utils.manual_sensor(props)) 225 sync_latency = camera_properties_utils.sync_latency(props) 226 logging.debug('sync latency: %d frames', sync_latency) 227 largest_yuv = capture_request_utils.get_largest_format('yuv', props) 228 match_ar = (largest_yuv['width'], largest_yuv['height']) 229 fmt = capture_request_utils.get_near_vga_yuv_format( 230 props, match_ar=match_ar) 231 e, s = target_exposure_utils.get_target_exposure_combos( 232 self.log_path, cam)['minSensitivity'] 233 234 # Take a shot and adjust parameters for brightness 235 logging.debug('Target exposure combo values. exp: %d, iso: %d', 236 e, s) 237 e = adjust_exp_for_brightness( 238 cam, props, fmt, e, s, sync_latency, test_name_with_path) 239 240 # Initialize values to define test range 241 s_e_product = s * e 242 expt_range = props['android.sensor.info.exposureTimeRange'] 243 sens_range = props['android.sensor.info.sensitivityRange'] 244 m = 1.0 245 246 # Do captures with a range of exposures, but constant s*e 247 while s*m < sens_range[1] and e/m > expt_range[0]: 248 mults.append(m) 249 s_req = round(s * m) 250 e_req = s_e_product // s_req 251 logging.debug('Testing s: %d, e: %dns', s_req, e_req) 252 req = capture_request_utils.manual_capture_request( 253 s_req, e_req, 0.0, True, props) 254 cap = its_session_utils.do_capture_with_latency( 255 cam, req, sync_latency, fmt) 256 s_res = cap['metadata']['android.sensor.sensitivity'] 257 e_res = cap['metadata']['android.sensor.exposureTime'] 258 # determine exposure tolerance based on exposure time 259 if e_req >= _THRESH_EXP_KNEE: 260 thresh_round_down_exp = _THRESH_ROUND_DOWN_EXP 261 else: 262 thresh_round_down_exp = ( 263 _THRESH_ROUND_DOWN_EXP + 264 (_THRESH_ROUND_DOWN_EXP0 - _THRESH_ROUND_DOWN_EXP) * 265 (_THRESH_EXP_KNEE - e_req) / _THRESH_EXP_KNEE) 266 if not 0 <= s_req - s_res < s_req * _THRESH_ROUND_DOWN_ISO: 267 raise AssertionError(f's_req: {s_req}, s_res: {s_res}, ' 268 f'RTOL=-{_THRESH_ROUND_DOWN_ISO*100}%') 269 if not 0 <= e_req - e_res < e_req * thresh_round_down_exp: 270 raise AssertionError(f'e_req: {e_req}ns, e_res: {e_res}ns, ' 271 f'RTOL=-{thresh_round_down_exp*100}%') 272 s_e_product_res = s_res * e_res 273 req_res_ratio = s_e_product / s_e_product_res 274 logging.debug('Capture result s: %d, e: %dns', s_res, e_res) 275 img = image_processing_utils.convert_capture_to_rgb_image(cap) 276 image_processing_utils.write_image( 277 img, f'{test_name_with_path}_mult={m:.2f}.jpg') 278 patch = image_processing_utils.get_image_patch( 279 img, _PATCH_X, _PATCH_Y, _PATCH_W, _PATCH_H) 280 rgb_means = image_processing_utils.compute_image_means(patch) 281 282 # Adjust for the difference between request and result 283 r_means.append(rgb_means[0] * req_res_ratio) 284 g_means.append(rgb_means[1] * req_res_ratio) 285 b_means.append(rgb_means[2] * req_res_ratio) 286 287 # Do with RAW_STATS space if debug 288 if raw_avlb and debug: 289 aaw, aah = get_raw_active_array_size(props) 290 fmt_raw = {'format': 'rawStats', 291 'gridWidth': aaw//_RAW_STATS_GRID, 292 'gridHeight': aah//_RAW_STATS_GRID} 293 raw_cap = its_session_utils.do_capture_with_latency( 294 cam, req, sync_latency, fmt_raw) 295 r, gr, gb, b = image_processing_utils.convert_capture_to_planes( 296 raw_cap, props) 297 raw_r_means.append(r[_RAW_STATS_XY, _RAW_STATS_XY] * req_res_ratio) 298 raw_gr_means.append(gr[_RAW_STATS_XY, _RAW_STATS_XY] * req_res_ratio) 299 raw_gb_means.append(gb[_RAW_STATS_XY, _RAW_STATS_XY] * req_res_ratio) 300 raw_b_means.append(b[_RAW_STATS_XY, _RAW_STATS_XY] * req_res_ratio) 301 302 # Test number of points per 2x gain 303 m *= pow(2, 1.0/_NUM_PTS_2X_GAIN) 304 305 # Loosen threshold for devices with wider exposure range 306 if m >= _WIDE_EXP_RANGE_THRESH: 307 thresh_max_level_diff = _THRESH_MAX_LEVEL_DIFF_WIDE_RANGE 308 309 # Draw plots and check data 310 if raw_avlb and debug: 311 plot_raw_means('RAW data', mults, raw_r_means, raw_gr_means, raw_gb_means, 312 raw_b_means, test_name_with_path) 313 for ch, color in enumerate(['R', 'Gr', 'Gb', 'B']): 314 values = [raw_r_means, raw_gr_means, raw_gb_means, raw_b_means][ch] 315 check_line_fit(color, mults, values, thresh_max_level_diff) 316 317 plot_rgb_means(f'RGB (1x: iso={s}, exp={e})', mults, 318 r_means, g_means, b_means, test_name_with_path) 319 for ch, color in enumerate(['R', 'G', 'B']): 320 values = [r_means, g_means, b_means][ch] 321 check_line_fit(color, mults, values, thresh_max_level_diff) 322 323if __name__ == '__main__': 324 test_runner.main() 325