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