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