xref: /aosp_15_r20/cts/apps/CameraITS/utils/image_processing_utils.py (revision b7c941bb3fa97aba169d73cee0bed2de8ac964bf)
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"""Image processing utility functions."""
15
16
17import copy
18import io
19import logging
20import math
21import matplotlib
22from matplotlib import pyplot as plt
23import os
24import sys
25
26import capture_request_utils
27import error_util
28import noise_model_constants
29import numpy
30from PIL import Image
31from PIL import ImageCms
32
33
34_CMAP_BLUE = ('black', 'blue', 'lightblue')
35_CMAP_GREEN = ('black', 'green', 'lightgreen')
36_CMAP_RED = ('black', 'red', 'lightcoral')
37_CMAP_SIZE = 6  # 6 inches
38_NUM_RAW_CHANNELS = 4  # R, Gr, Gb, B
39
40LENS_SHADING_MAP_ON = 1
41
42# The matrix is from JFIF spec
43DEFAULT_YUV_TO_RGB_CCM = numpy.matrix([[1.000, 0.000, 1.402],
44                                       [1.000, -0.344, -0.714],
45                                       [1.000, 1.772, 0.000]])
46
47DEFAULT_YUV_OFFSETS = numpy.array([0, 128, 128], dtype=numpy.uint8)
48MAX_LUT_SIZE = 65536
49DEFAULT_GAMMA_LUT = numpy.array([
50    math.floor((MAX_LUT_SIZE-1) * math.pow(i/(MAX_LUT_SIZE-1), 1/2.2) + 0.5)
51    for i in range(MAX_LUT_SIZE)])
52RGB2GRAY_WEIGHTS = (0.299, 0.587, 0.114)
53TEST_IMG_DIR = os.path.join(os.environ['CAMERA_ITS_TOP'], 'test_images')
54
55# Expected adapted primaries in ICC profile per color space
56EXPECTED_RX_P3 = 0.682
57EXPECTED_RY_P3 = 0.319
58EXPECTED_GX_P3 = 0.285
59EXPECTED_GY_P3 = 0.675
60EXPECTED_BX_P3 = 0.156
61EXPECTED_BY_P3 = 0.066
62
63EXPECTED_RX_SRGB = 0.648
64EXPECTED_RY_SRGB = 0.331
65EXPECTED_GX_SRGB = 0.321
66EXPECTED_GY_SRGB = 0.598
67EXPECTED_BX_SRGB = 0.156
68EXPECTED_BY_SRGB = 0.066
69
70# Color conversion matrix for DISPLAY P3 to CIEXYZ
71P3_TO_XYZ = numpy.array([
72    [0.5151187, 0.2919778, 0.1571035],
73    [0.2411892, 0.6922441, 0.0665668],
74    [-0.0010505, 0.0418791, 0.7840713]
75]).transpose()
76
77# Chosen empirically - tolerance for the point in triangle test for colorspace
78# chromaticities
79COLORSPACE_TRIANGLE_AREA_TOL = 0.00039
80
81
82def plot_lsc_maps(lsc_maps, plot_name, test_name_with_log_path):
83  """Plot the lens shading correction maps.
84
85  Args:
86    lsc_maps: 4D np array; r, gr, gb, b lens shading correction maps.
87    plot_name: str; identifier for maps ('full_scale' or 'metadata').
88    test_name_with_log_path: str; test name with log_path location.
89
90  Returns:
91    None, but generates and saves plots.
92  """
93  aspect_ratio = lsc_maps[:, :, 0].shape[1] / lsc_maps[:, :, 0].shape[0]
94  plot_w = 1 + aspect_ratio * _CMAP_SIZE  # add 1 for heatmap legend
95  plt.figure(plot_name, figsize=(plot_w, _CMAP_SIZE))
96  plt.suptitle(plot_name)
97
98  plt.subplot(2, 2, 1)  # 2x2 top left
99  plt.title('R')
100  cmap = matplotlib.colors.LinearSegmentedColormap.from_list('', _CMAP_RED)
101  plt.pcolormesh(lsc_maps[:, :, 0], cmap=cmap)
102  plt.colorbar()
103
104  plt.subplot(2, 2, 2)  # 2x2 top right
105  plt.title('Gr')
106  cmap = matplotlib.colors.LinearSegmentedColormap.from_list('', _CMAP_GREEN)
107  plt.pcolormesh(lsc_maps[:, :, 1], cmap=cmap)
108  plt.colorbar()
109
110  plt.subplot(2, 2, 3)  # 2x2 bottom left
111  plt.title('Gb')
112  cmap = matplotlib.colors.LinearSegmentedColormap.from_list('', _CMAP_GREEN)
113  plt.pcolormesh(lsc_maps[:, :, 2], cmap=cmap)
114  plt.colorbar()
115
116  plt.subplot(2, 2, 4)  # 2x2 bottom right
117  plt.title('B')
118  cmap = matplotlib.colors.LinearSegmentedColormap.from_list('', _CMAP_BLUE)
119  plt.pcolormesh(lsc_maps[:, :, 3], cmap=cmap)
120  plt.colorbar()
121
122  plt.savefig(f'{test_name_with_log_path}_{plot_name}_cmaps.png')
123
124
125def capture_scene_image(cam, props, name_with_log_path):
126  """Take a picture of the scene on test FAIL."""
127  req = capture_request_utils.auto_capture_request()
128  img = convert_capture_to_rgb_image(
129      cam.do_capture(req, cam.CAP_YUV), props=props)
130  write_image(img, f'{name_with_log_path}_scene.jpg', True)
131
132
133def convert_image_to_uint8(image):
134  image = image*255
135  return image.astype(numpy.uint8)
136
137
138def assert_props_is_not_none(props):
139  if not props:
140    raise AssertionError('props is None')
141
142
143def assert_capture_width_and_height(cap, width, height):
144  if cap['width'] != width or cap['height'] != height:
145    raise AssertionError(
146        'Unexpected capture WxH size, expected [{}x{}], actual [{}x{}]'.format(
147            width, height, cap['width'], cap['height']
148        )
149    )
150
151
152def apply_lens_shading_map(color_plane, black_level, white_level, lsc_map):
153  """Apply the lens shading map to the color plane.
154
155  Args:
156    color_plane: 2D np array for color plane with values [0.0, 1.0].
157    black_level: float; black level for the color plane.
158    white_level: int; full scale for the color plane.
159    lsc_map: 2D np array lens shading matching size of color_plane.
160
161  Returns:
162    color_plane with lsc applied.
163  """
164  logging.debug('color plane pre-lsc min, max: %.4f, %.4f',
165                numpy.min(color_plane), numpy.max(color_plane))
166  color_plane = (numpy.multiply((color_plane * white_level - black_level),
167                                lsc_map)
168                 + black_level) / white_level
169  logging.debug('color plane post-lsc min, max: %.4f, %.4f',
170                numpy.min(color_plane), numpy.max(color_plane))
171  return color_plane
172
173
174def populate_lens_shading_map(img_shape, lsc_map):
175  """Helper function to create LSC coeifficients for RAW image.
176
177  Args:
178    img_shape: tuple; RAW image shape.
179    lsc_map: 2D low resolution array with lens shading map values.
180
181  Returns:
182    value for lens shading map at point (x, y) in the image.
183  """
184  img_w, img_h = img_shape[1], img_shape[0]
185  map_w, map_h = lsc_map.shape[1], lsc_map.shape[0]
186
187  x, y = numpy.meshgrid(numpy.arange(img_w), numpy.arange(img_h))
188
189  # (u,v) is lsc map location, values [0, map_w-1], [0, map_h-1]
190  # Vectorized calculations
191  u = x * (map_w - 1) / (img_w - 1)
192  v = y * (map_h - 1) / (img_h - 1)
193  u_min = numpy.floor(u).astype(int)
194  v_min = numpy.floor(v).astype(int)
195  u_frac = u - u_min
196  v_frac = v - v_min
197  u_max = numpy.where(u_frac > 0, u_min + 1, u_min)
198  v_max = numpy.where(v_frac > 0, v_min + 1, v_min)
199
200  # Gather LSC values, handling edge cases (optional)
201  lsc_tl = lsc_map[(v_min, u_min)]
202  lsc_tr = lsc_map[(v_min, u_max)]
203  lsc_bl = lsc_map[(v_max, u_min)]
204  lsc_br = lsc_map[(v_max, u_max)]
205
206  # Bilinear interpolation (vectorized)
207  lsc_t = lsc_tl * (1 - u_frac) + lsc_tr * u_frac
208  lsc_b = lsc_bl * (1 - u_frac) + lsc_br * u_frac
209
210  return lsc_t * (1 - v_frac) + lsc_b * v_frac
211
212
213def unpack_lsc_map_from_metadata(metadata):
214  """Get lens shading correction map from metadata and turn into 3D array.
215
216  Args:
217    metadata: dict; metadata from RAW capture.
218
219  Returns:
220    3D numpy array of lens shading maps.
221  """
222  lsc_metadata = metadata['android.statistics.lensShadingCorrectionMap']
223  lsc_map_w, lsc_map_h = lsc_metadata['width'], lsc_metadata['height']
224  lsc_map = lsc_metadata['map']
225  logging.debug(
226      'lensShadingCorrectionMap (H, W): (%d, %d)', lsc_map_h, lsc_map_w
227  )
228  return numpy.array(lsc_map).reshape(lsc_map_h, lsc_map_w, _NUM_RAW_CHANNELS)
229
230
231def convert_raw_capture_to_rgb_image(cap_raw, props, raw_fmt,
232                                     log_path_with_name):
233  """Convert a RAW captured image object to a RGB image.
234
235  Args:
236    cap_raw: A RAW capture object as returned by its_session_utils.do_capture.
237    props: camera properties object (of static values).
238    raw_fmt: string of type 'raw', 'raw10', 'raw12'.
239    log_path_with_name: string with test name and save location.
240
241  Returns:
242    RGB float-3 image array, with pixel values in [0.0, 1.0].
243  """
244  shading_mode = cap_raw['metadata']['android.shading.mode']
245  lens_shading_map_mode = cap_raw[
246      'metadata'].get('android.statistics.lensShadingMapMode')
247  lens_shading_applied = props['android.sensor.info.lensShadingApplied']
248  control_af_mode = cap_raw['metadata']['android.control.afMode']
249  focus_distance = cap_raw['metadata']['android.lens.focusDistance']
250  logging.debug('%s capture AF mode: %s', raw_fmt, control_af_mode)
251  logging.debug('%s capture focus distance: %s', raw_fmt, focus_distance)
252  logging.debug('%s capture shading mode: %d', raw_fmt, shading_mode)
253  logging.debug('lensShadingMapApplied: %r', lens_shading_applied)
254  logging.debug('lensShadingMapMode: %s', lens_shading_map_mode)
255
256  # Split RAW to RGB conversion in 2 to allow LSC application (if needed).
257  r, gr, gb, b = convert_capture_to_planes(cap_raw, props=props)
258
259  # get from metadata, upsample, and apply
260  if lens_shading_map_mode == LENS_SHADING_MAP_ON:
261    logging.debug('Applying lens shading map')
262    plot_name_stem_with_log_path = f'{log_path_with_name}_{raw_fmt}'
263    black_levels = get_black_levels(props, cap_raw)
264    white_level = int(props['android.sensor.info.whiteLevel'])
265    lsc_maps = unpack_lsc_map_from_metadata(cap_raw['metadata'])
266    plot_lsc_maps(lsc_maps, 'metadata', plot_name_stem_with_log_path)
267    lsc_map_fs_r = populate_lens_shading_map(r.shape, lsc_maps[:, :, 0])
268    lsc_map_fs_gr = populate_lens_shading_map(gr.shape, lsc_maps[:, :, 1])
269    lsc_map_fs_gb = populate_lens_shading_map(gb.shape, lsc_maps[:, :, 2])
270    lsc_map_fs_b = populate_lens_shading_map(b.shape, lsc_maps[:, :, 3])
271    plot_lsc_maps(
272        numpy.dstack((lsc_map_fs_r, lsc_map_fs_gr, lsc_map_fs_gb,
273                      lsc_map_fs_b)),
274        'fullscale', plot_name_stem_with_log_path
275    )
276    r = apply_lens_shading_map(
277        r[:, :, 0], black_levels[0], white_level, lsc_map_fs_r
278    )
279    gr = apply_lens_shading_map(
280        gr[:, :, 0], black_levels[1], white_level, lsc_map_fs_gr
281    )
282    gb = apply_lens_shading_map(
283        gb[:, :, 0], black_levels[2], white_level, lsc_map_fs_gb
284    )
285    b = apply_lens_shading_map(
286        b[:, :, 0], black_levels[3], white_level, lsc_map_fs_b
287    )
288  img = convert_raw_to_rgb_image(r, gr, gb, b, props, cap_raw['metadata'])
289  return img
290
291
292def convert_capture_to_rgb_image(cap,
293                                 props=None,
294                                 apply_ccm_raw_to_rgb=True):
295  """Convert a captured image object to a RGB image.
296
297  Args:
298     cap: A capture object as returned by its_session_utils.do_capture.
299     props: (Optional) camera properties object (of static values);
300            required for processing raw images.
301     apply_ccm_raw_to_rgb: (Optional) boolean to apply color correction matrix.
302
303  Returns:
304        RGB float-3 image array, with pixel values in [0.0, 1.0].
305  """
306  w = cap['width']
307  h = cap['height']
308  if cap['format'] == 'raw10' or cap['format'] == 'raw10QuadBayer':
309    assert_props_is_not_none(props)
310    is_quad_bayer = cap['format'] == 'raw10QuadBayer'
311    cap = unpack_raw10_capture(cap, is_quad_bayer)
312
313  if cap['format'] == 'raw12':
314    assert_props_is_not_none(props)
315    cap = unpack_raw12_capture(cap)
316
317  if cap['format'] == 'yuv':
318    y = cap['data'][0: w * h]
319    u = cap['data'][w * h: w * h * 5//4]
320    v = cap['data'][w * h * 5//4: w * h * 6//4]
321    return convert_yuv420_planar_to_rgb_image(y, u, v, w, h)
322  elif cap['format'] == 'jpeg' or cap['format'] == 'jpeg_r':
323    return decompress_jpeg_to_rgb_image(cap['data'])
324  elif (cap['format'] in ('raw', 'rawQuadBayer') or
325        cap['format'] in noise_model_constants.VALID_RAW_STATS_FORMATS):
326    assert_props_is_not_none(props)
327    r, gr, gb, b = convert_capture_to_planes(cap, props)
328    return convert_raw_to_rgb_image(
329        r, gr, gb, b, props, cap['metadata'], apply_ccm_raw_to_rgb)
330  elif cap['format'] == 'y8':
331    y = cap['data'][0: w * h]
332    return convert_y8_to_rgb_image(y, w, h)
333  else:
334    raise error_util.CameraItsError(f"Invalid format {cap['format']}")
335
336
337def unpack_raw10_capture(cap, is_quad_bayer=False):
338  """Unpack a raw-10 capture to a raw-16 capture.
339
340  Args:
341    cap: A raw-10 capture object.
342    is_quad_bayer: Boolean flag for Bayer or Quad Bayer capture.
343
344  Returns:
345    New capture object with raw-16 data.
346  """
347  # Data is packed as 4x10b pixels in 5 bytes, with the first 4 bytes holding
348  # the MSBs of the pixels, and the 5th byte holding 4x2b LSBs.
349  w, h = cap['width'], cap['height']
350  if w % 4 != 0:
351    raise error_util.CameraItsError('Invalid raw-10 buffer width')
352  cap = copy.deepcopy(cap)
353  cap['data'] = unpack_raw10_image(cap['data'].reshape(h, w * 5 // 4))
354  cap['format'] = 'rawQuadBayer' if is_quad_bayer else 'raw'
355  return cap
356
357
358def unpack_raw10_image(img):
359  """Unpack a raw-10 image to a raw-16 image.
360
361  Output image will have the 10 LSBs filled in each 16b word, and the 6 MSBs
362  will be set to zero.
363
364  Args:
365    img: A raw-10 image, as a uint8 numpy array.
366
367  Returns:
368    Image as a uint16 numpy array, with all row padding stripped.
369  """
370  if img.shape[1] % 5 != 0:
371    raise error_util.CameraItsError('Invalid raw-10 buffer width')
372  w = img.shape[1] * 4 // 5
373  h = img.shape[0]
374  # Cut out the 4x8b MSBs and shift to bits [9:2] in 16b words.
375  msbs = numpy.delete(img, numpy.s_[4::5], 1)
376  msbs = msbs.astype(numpy.uint16)
377  msbs = numpy.left_shift(msbs, 2)
378  msbs = msbs.reshape(h, w)
379  # Cut out the 4x2b LSBs and put each in bits [1:0] of their own 8b words.
380  lsbs = img[::, 4::5].reshape(h, w // 4)
381  lsbs = numpy.right_shift(
382      numpy.packbits(numpy.unpackbits(lsbs).reshape((h, w // 4, 4, 2)), 3), 6)
383  # Pair the LSB bits group to 0th pixel instead of 3rd pixel
384  lsbs = lsbs.reshape(h, w // 4, 4)[:, :, ::-1]
385  lsbs = lsbs.reshape(h, w)
386  # Fuse the MSBs and LSBs back together
387  img16 = numpy.bitwise_or(msbs, lsbs).reshape(h, w)
388  return img16
389
390
391def unpack_raw12_capture(cap):
392  """Unpack a raw-12 capture to a raw-16 capture.
393
394  Args:
395    cap: A raw-12 capture object.
396
397  Returns:
398     New capture object with raw-16 data.
399  """
400  # Data is packed as 4x10b pixels in 5 bytes, with the first 4 bytes holding
401  # the MSBs of the pixels, and the 5th byte holding 4x2b LSBs.
402  w, h = cap['width'], cap['height']
403  if w % 2 != 0:
404    raise error_util.CameraItsError('Invalid raw-12 buffer width')
405  cap = copy.deepcopy(cap)
406  cap['data'] = unpack_raw12_image(cap['data'].reshape(h, w * 3 // 2))
407  cap['format'] = 'raw'
408  return cap
409
410
411def unpack_raw12_image(img):
412  """Unpack a raw-12 image to a raw-16 image.
413
414  Output image will have the 12 LSBs filled in each 16b word, and the 4 MSBs
415  will be set to zero.
416
417  Args:
418   img: A raw-12 image, as a uint8 numpy array.
419
420  Returns:
421    Image as a uint16 numpy array, with all row padding stripped.
422  """
423  if img.shape[1] % 3 != 0:
424    raise error_util.CameraItsError('Invalid raw-12 buffer width')
425  w = img.shape[1] * 2 // 3
426  h = img.shape[0]
427  # Cut out the 2x8b MSBs and shift to bits [11:4] in 16b words.
428  msbs = numpy.delete(img, numpy.s_[2::3], 1)
429  msbs = msbs.astype(numpy.uint16)
430  msbs = numpy.left_shift(msbs, 4)
431  msbs = msbs.reshape(h, w)
432  # Cut out the 2x4b LSBs and put each in bits [3:0] of their own 8b words.
433  lsbs = img[::, 2::3].reshape(h, w // 2)
434  lsbs = numpy.right_shift(
435      numpy.packbits(numpy.unpackbits(lsbs).reshape((h, w // 2, 2, 4)), 3), 4)
436  # Pair the LSB bits group to pixel 0 instead of pixel 1
437  lsbs = lsbs.reshape(h, w // 2, 2)[:, :, ::-1]
438  lsbs = lsbs.reshape(h, w)
439  # Fuse the MSBs and LSBs back together
440  img16 = numpy.bitwise_or(msbs, lsbs).reshape(h, w)
441  return img16
442
443
444def convert_yuv420_planar_to_rgb_image(y_plane, u_plane, v_plane,
445                                       w, h,
446                                       ccm_yuv_to_rgb=DEFAULT_YUV_TO_RGB_CCM,
447                                       yuv_off=DEFAULT_YUV_OFFSETS):
448  """Convert a YUV420 8-bit planar image to an RGB image.
449
450  Args:
451    y_plane: The packed 8-bit Y plane.
452    u_plane: The packed 8-bit U plane.
453    v_plane: The packed 8-bit V plane.
454    w: The width of the image.
455    h: The height of the image.
456    ccm_yuv_to_rgb: (Optional) the 3x3 CCM to convert from YUV to RGB.
457    yuv_off: (Optional) offsets to subtract from each of Y,U,V values.
458
459  Returns:
460    RGB float-3 image array, with pixel values in [0.0, 1.0].
461  """
462  y = numpy.subtract(y_plane, yuv_off[0])
463  u = numpy.subtract(u_plane, yuv_off[1]).view(numpy.int8)
464  v = numpy.subtract(v_plane, yuv_off[2]).view(numpy.int8)
465  u = u.reshape(h // 2, w // 2).repeat(2, axis=1).repeat(2, axis=0)
466  v = v.reshape(h // 2, w // 2).repeat(2, axis=1).repeat(2, axis=0)
467  yuv = numpy.dstack([y, u.reshape(w * h), v.reshape(w * h)])
468  flt = numpy.empty([h, w, 3], dtype=numpy.float32)
469  flt.reshape(w * h * 3)[:] = yuv.reshape(h * w * 3)[:]
470  flt = numpy.dot(flt.reshape(w * h, 3), ccm_yuv_to_rgb.T).clip(0, 255)
471  rgb = numpy.empty([h, w, 3], dtype=numpy.uint8)
472  rgb.reshape(w * h * 3)[:] = flt.reshape(w * h * 3)[:]
473  return rgb.astype(numpy.float32) / 255.0
474
475
476def decompress_jpeg_to_rgb_image(jpeg_buffer):
477  """Decompress a JPEG-compressed image, returning as an RGB image.
478
479  Args:
480    jpeg_buffer: The JPEG stream.
481
482  Returns:
483     A numpy array for the RGB image, with pixels in [0,1].
484  """
485  img = Image.open(io.BytesIO(jpeg_buffer))
486  w = img.size[0]
487  h = img.size[1]
488  return numpy.array(img).reshape((h, w, 3)) / 255.0
489
490
491def decompress_jpeg_to_yuv_image(jpeg_buffer):
492  """Decompress a JPEG-compressed image, returning as a YUV image.
493
494  Args:
495    jpeg_buffer: The JPEG stream.
496
497  Returns:
498     A numpy array for the YUV image, with pixels in [0,1].
499  """
500  img = Image.open(io.BytesIO(jpeg_buffer))
501  img = img.convert('YCbCr')
502  w = img.size[0]
503  h = img.size[1]
504  return numpy.array(img).reshape((h, w, 3)) / 255.0
505
506
507def extract_luma_from_patch(cap, patch_x, patch_y, patch_w, patch_h):
508  """Extract luma from capture."""
509  y, _, _ = convert_capture_to_planes(cap)
510  patch = get_image_patch(y, patch_x, patch_y, patch_w, patch_h)
511  luma = compute_image_means(patch)[0]
512  return luma
513
514
515def convert_image_to_numpy_array(image_path):
516  """Converts image at image_path to numpy array and returns the array.
517
518  Args:
519    image_path: file path
520
521  Returns:
522    numpy array
523  """
524  if not os.path.exists(image_path):
525    raise AssertionError(f'{image_path} does not exist.')
526  image = Image.open(image_path)
527  return numpy.array(image)
528
529
530def _convert_quad_bayer_img_to_bayer_channels(quad_bayer_img, props=None):
531  """Convert a quad Bayer image to the Bayer image channels.
532
533  Args:
534      quad_bayer_img: The quad Bayer image.
535      props: The camera properties.
536
537  Returns:
538      A list of reordered standard Bayer channels of the Bayer image.
539  """
540  height, width, num_channels = quad_bayer_img.shape
541
542  if num_channels != noise_model_constants.NUM_QUAD_BAYER_CHANNELS:
543    raise AssertionError(
544        'The number of channels in the quad Bayer image must be '
545        f'{noise_model_constants.NUM_QUAD_BAYER_CHANNELS}.'
546    )
547  quad_bayer_cfa_order = get_canonical_cfa_order(props, is_quad_bayer=True)
548
549  # Bayer channels are in the order of R, Gr, Gb and B.
550  bayer_channels = []
551  for ch in range(4):
552    channel_img = numpy.zeros(shape=(height, width), dtype='<f')
553    # Average every four quad Bayer channels into a standard Bayer channel.
554    for i in quad_bayer_cfa_order[4 * ch: 4 * (ch + 1)]:
555      channel_img[:, :] += quad_bayer_img[:, :, i]
556    bayer_channels.append(channel_img / 4)
557  return bayer_channels
558
559
560def subsample(image, num_channels=4):
561  """Subsamples the image to separate its color channels.
562
563  Args:
564    image:        2-D numpy array of raw image.
565    num_channels: The number of channels in the image.
566
567  Returns:
568    3-D numpy image with each channel separated.
569  """
570  if num_channels not in noise_model_constants.VALID_NUM_CHANNELS:
571    raise error_util.CameraItsError(
572        f'Invalid number of channels {num_channels}, which should be in '
573        f'{noise_model_constants.VALID_NUM_CHANNELS}.'
574    )
575
576  size_h, size_v = image.shape[1], image.shape[0]
577
578  # Subsample step size, which is the horizontal or vertical pixel interval
579  # between two adjacent pixels of the same channel.
580  stride = int(numpy.sqrt(num_channels))
581  subsample_img = lambda img, i, h, v, s: img[i // s: v: s, i % s: h: s]
582  channel_img = numpy.empty((
583      image.shape[0] // stride,
584      image.shape[1] // stride,
585      num_channels,
586  ))
587
588  for i in range(num_channels):
589    sub_img = subsample_img(image, i, size_h, size_v, stride)
590    channel_img[:, :, i] = sub_img
591
592  return channel_img
593
594
595def convert_capture_to_planes(cap, props=None):
596  """Convert a captured image object to separate image planes.
597
598  Decompose an image into multiple images, corresponding to different planes.
599
600  For YUV420 captures ("yuv"):
601        Returns Y,U,V planes, where the Y plane is full-res and the U,V planes
602        are each 1/2 x 1/2 of the full res.
603
604    For standard Bayer or quad Bayer captures ("raw", "raw10", "raw12",
605    "rawQuadBayer", "rawStats", "rawQuadBayerStats", "raw10QuadBayer",
606    "raw10Stats", "raw10QuadBayerStats"):
607        Returns planes in the order R, Gr, Gb, B, regardless of the Bayer
608        pattern layout.
609        For full-res raw images ("raw", "rawQuadBayer", "raw10",
610        "raw10QuadBayer", "raw12"), each plane is 1/2 x 1/2 of the full res.
611        For standard Bayer stats images, the mean image is returned.
612        For quad Bayer stats images, the average mean image is returned.
613
614    For JPEG captures ("jpeg"):
615        Returns R,G,B full-res planes.
616
617  Args:
618    cap: A capture object as returned by its_session_utils.do_capture.
619    props: (Optional) camera properties object (of static values);
620            required for processing raw images.
621
622  Returns:
623    A tuple of float numpy arrays (one per plane), consisting of pixel values
624    in the range [0.0, 1.0].
625  """
626  w = cap['width']
627  h = cap['height']
628  if cap['format'] in ('raw10', 'raw10QuadBayer'):
629    assert_props_is_not_none(props)
630    is_quad_bayer = cap['format'] == 'raw10QuadBayer'
631    cap = unpack_raw10_capture(cap, is_quad_bayer)
632
633  if cap['format'] == 'raw12':
634    assert_props_is_not_none(props)
635    cap = unpack_raw12_capture(cap)
636  if cap['format'] == 'yuv':
637    y = cap['data'][0:w * h]
638    u = cap['data'][w * h:w * h * 5 // 4]
639    v = cap['data'][w * h * 5 // 4:w * h * 6 // 4]
640    return ((y.astype(numpy.float32) / 255.0).reshape(h, w, 1),
641            (u.astype(numpy.float32) / 255.0).reshape(h // 2, w // 2, 1),
642            (v.astype(numpy.float32) / 255.0).reshape(h // 2, w // 2, 1))
643  elif cap['format'] == 'jpeg':
644    rgb = decompress_jpeg_to_rgb_image(cap['data']).reshape(w * h * 3)
645    return (rgb[::3].reshape(h, w, 1), rgb[1::3].reshape(h, w, 1),
646            rgb[2::3].reshape(h, w, 1))
647  elif cap['format'] in ('raw', 'rawQuadBayer'):
648    assert_props_is_not_none(props)
649    is_quad_bayer = 'QuadBayer' in cap['format']
650    white_level = get_white_level(props, cap['metadata'])
651    img = numpy.ndarray(
652        shape=(h * w,), dtype='<u2', buffer=cap['data'][0:w * h * 2])
653    img = img.astype(numpy.float32).reshape(h, w) / white_level
654    if is_quad_bayer:
655      pixel_array_size = props.get(
656          'android.sensor.info.pixelArraySizeMaximumResolution'
657      )
658      active_array_size = props.get(
659          'android.sensor.info.preCorrectionActiveArraySizeMaximumResolution'
660      )
661    else:
662      pixel_array_size = props.get('android.sensor.info.pixelArraySize')
663      active_array_size = props.get(
664          'android.sensor.info.preCorrectionActiveArraySize'
665      )
666    # Crop the raw image to the active array region.
667    if pixel_array_size and active_array_size:
668      # Note that the Rect class is defined such that the left,top values
669      # are "inside" while the right,bottom values are "outside"; that is,
670      # it's inclusive of the top,left sides only. So, the width is
671      # computed as right-left, rather than right-left+1, etc.
672      wfull = pixel_array_size['width']
673      hfull = pixel_array_size['height']
674      xcrop = active_array_size['left']
675      ycrop = active_array_size['top']
676      wcrop = active_array_size['right'] - xcrop
677      hcrop = active_array_size['bottom'] - ycrop
678      if not wfull >= wcrop >= 0:
679        raise AssertionError(f'wcrop: {wcrop} not in wfull: {wfull}')
680      if not hfull >= hcrop >= 0:
681        raise AssertionError(f'hcrop: {hcrop} not in hfull: {hfull}')
682      if not wfull - wcrop >= xcrop >= 0:
683        raise AssertionError(f'xcrop: {xcrop} not in wfull-crop: {wfull-wcrop}')
684      if not hfull - hcrop >= ycrop >= 0:
685        raise AssertionError(f'ycrop: {ycrop} not in hfull-crop: {hfull-hcrop}')
686      if w == wfull and h == hfull:
687        # Crop needed; extract the center region.
688        img = img[ycrop:ycrop + hcrop, xcrop:xcrop + wcrop]
689        w = wcrop
690        h = hcrop
691      elif w == wcrop and h == hcrop:
692        logging.debug('Image is already cropped. No cropping needed.')
693      else:
694        raise error_util.CameraItsError('Invalid image size metadata')
695
696    idxs = get_canonical_cfa_order(props, is_quad_bayer)
697    if is_quad_bayer:
698      # Subsample image array based on the color map.
699      quad_bayer_img = subsample(
700          img, noise_model_constants.NUM_QUAD_BAYER_CHANNELS
701      )
702      bayer_channels = _convert_quad_bayer_img_to_bayer_channels(
703          quad_bayer_img, props
704      )
705      return bayer_channels
706    else:
707      # Separate the image planes.
708      imgs = [
709          img[::2].reshape(w * h // 2)[::2].reshape(h // 2, w // 2, 1),
710          img[::2].reshape(w * h // 2)[1::2].reshape(h // 2, w // 2, 1),
711          img[1::2].reshape(w * h // 2)[::2].reshape(h // 2, w // 2, 1),
712          img[1::2].reshape(w * h // 2)[1::2].reshape(h // 2, w // 2, 1),
713      ]
714      return [imgs[i] for i in idxs]
715  elif cap['format'] in (
716      'rawStats',
717      'raw10Stats',
718      'rawQuadBayerStats',
719      'raw10QuadBayerStats',
720  ):
721    assert_props_is_not_none(props)
722    is_quad_bayer = 'QuadBayer' in cap['format']
723    white_level = get_white_level(props, cap['metadata'])
724    if is_quad_bayer:
725      num_channels = noise_model_constants.NUM_QUAD_BAYER_CHANNELS
726    else:
727      num_channels = noise_model_constants.NUM_BAYER_CHANNELS
728    mean_image, _ = unpack_rawstats_capture(cap, num_channels)
729    if is_quad_bayer:
730      bayer_channels = _convert_quad_bayer_img_to_bayer_channels(
731          mean_image, props
732      )
733      bayer_channels = [
734          bayer_channels[i] / white_level for i in range(len(bayer_channels))
735      ]
736      return bayer_channels
737    else:
738      # Standard Bayer canonical color channel indices.
739      idxs = get_canonical_cfa_order(props, is_quad_bayer=False)
740      # Normalizes the range to [0, 1] without subtracting the black level.
741      return [mean_image[:, :, i] / white_level for i in idxs]
742  else:
743    raise error_util.CameraItsError(f"Invalid format {cap['format']}")
744
745
746def downscale_image(img, f):
747  """Shrink an image by a given integer factor.
748
749  This function computes output pixel values by averaging over rectangular
750  regions of the input image; it doesn't skip or sample pixels, and all input
751  image pixels are evenly weighted.
752
753  If the downscaling factor doesn't cleanly divide the width and/or height,
754  then the remaining pixels on the right or bottom edge are discarded prior
755  to the downscaling.
756
757  Args:
758    img: The input image as an ndarray.
759    f: The downscaling factor, which should be an integer.
760
761  Returns:
762    The new (downscaled) image, as an ndarray.
763  """
764  h, w, chans = img.shape
765  f = int(f)
766  assert f >= 1
767  h = (h//f)*f
768  w = (w//f)*f
769  img = img[0:h:, 0:w:, ::]
770  chs = []
771  for i in range(chans):
772    ch = img.reshape(h*w*chans)[i::chans].reshape(h, w)
773    ch = ch.reshape(h, w//f, f).mean(2).reshape(h, w//f)
774    ch = ch.T.reshape(w//f, h//f, f).mean(2).T.reshape(h//f, w//f)
775    chs.append(ch.reshape(h*w//(f*f)))
776  img = numpy.vstack(chs).T.reshape(h//f, w//f, chans)
777  return img
778
779
780def convert_raw_to_rgb_image(r_plane, gr_plane, gb_plane, b_plane, props,
781                             cap_res, apply_ccm_raw_to_rgb=True):
782  """Convert a Bayer raw-16 image to an RGB image.
783
784  Includes some extremely rudimentary demosaicking and color processing
785  operations; the output of this function shouldn't be used for any image
786  quality analysis.
787
788  Args:
789   r_plane:
790   gr_plane:
791   gb_plane:
792   b_plane: Numpy arrays for each color plane
793            in the Bayer image, with pixels in the [0.0, 1.0] range.
794   props: Camera properties object.
795   cap_res: Capture result (metadata) object.
796   apply_ccm_raw_to_rgb: (Optional) boolean to apply color correction matrix.
797
798  Returns:
799   RGB float-3 image array, with pixel values in [0.0, 1.0]
800  """
801  # Values required for the RAW to RGB conversion.
802  assert_props_is_not_none(props)
803  white_level = get_white_level(props, cap_res)
804  gains = cap_res['android.colorCorrection.gains']
805  ccm = cap_res['android.colorCorrection.transform']
806
807  # Reorder black levels and gains to R,Gr,Gb,B, to match the order
808  # of the planes.
809  black_levels = get_black_levels(props, cap_res, is_quad_bayer=False)
810  logging.debug('dynamic black levels: %s', black_levels)
811  gains = get_gains_in_canonical_order(props, gains)
812
813  # Convert CCM from rational to float, as numpy arrays.
814  ccm = numpy.array(capture_request_utils.rational_to_float(ccm)).reshape(3, 3)
815
816  # Need to scale the image back to the full [0,1] range after subtracting
817  # the black level from each pixel.
818  scale = white_level / (white_level - max(black_levels))
819
820  # Three-channel black levels, normalized to [0,1] by white_level.
821  black_levels = numpy.array(
822      [b / white_level for b in [black_levels[i] for i in [0, 1, 3]]])
823
824  # Three-channel gains.
825  gains = numpy.array([gains[i] for i in [0, 1, 3]])
826
827  h, w = r_plane.shape[:2]
828  img = numpy.dstack([r_plane, (gr_plane + gb_plane) / 2.0, b_plane])
829  img = (((img.reshape(h, w, 3) - black_levels) * scale) * gains).clip(0.0, 1.0)
830  if apply_ccm_raw_to_rgb:
831    img = numpy.dot(
832        img.reshape(w * h, 3), ccm.T).reshape((h, w, 3)).clip(0.0, 1.0)
833  return img
834
835
836def convert_y8_to_rgb_image(y_plane, w, h):
837  """Convert a Y 8-bit image to an RGB image.
838
839  Args:
840    y_plane: The packed 8-bit Y plane.
841    w: The width of the image.
842    h: The height of the image.
843
844  Returns:
845    RGB float-3 image array, with pixel values in [0.0, 1.0].
846  """
847  y3 = numpy.dstack([y_plane, y_plane, y_plane])
848  rgb = numpy.empty([h, w, 3], dtype=numpy.uint8)
849  rgb.reshape(w * h * 3)[:] = y3.reshape(w * h * 3)[:]
850  return rgb.astype(numpy.float32) / 255.0
851
852
853def write_rgb_uint8_image(img, file_name):
854  """Save a uint8 numpy array image to a file.
855
856  Supported formats: PNG, JPEG, and others; see PIL docs for more.
857
858  Args:
859   img: numpy image array data.
860   file_name: path of file to save to; the extension specifies the format.
861  """
862  if img.dtype != 'uint8':
863    raise AssertionError(f'Incorrect input type: {img.dtype}! Expected: uint8')
864  else:
865    Image.fromarray(img, 'RGB').save(file_name)
866
867
868def write_image(img, fname, apply_gamma=False, is_yuv=False):
869  """Save a float-3 numpy array image to a file.
870
871  Supported formats: PNG, JPEG, and others; see PIL docs for more.
872
873  Image can be 3-channel, which is interpreted as RGB or YUV, or can be
874  1-channel, which is greyscale.
875
876  Can optionally specify that the image should be gamma-encoded prior to
877  writing it out; this should be done if the image contains linear pixel
878  values, to make the image look "normal".
879
880  Args:
881   img: Numpy image array data.
882   fname: Path of file to save to; the extension specifies the format.
883   apply_gamma: (Optional) apply gamma to the image prior to writing it.
884   is_yuv: Whether the image is in YUV format.
885  """
886  if apply_gamma:
887    img = apply_lut_to_image(img, DEFAULT_GAMMA_LUT)
888  (h, w, chans) = img.shape
889  if chans == 3:
890    if not is_yuv:
891      Image.fromarray((img * 255.0).astype(numpy.uint8), 'RGB').save(fname)
892    else:
893      Image.fromarray((img * 255.0).astype(numpy.uint8), 'YCbCr').save(fname)
894  elif chans == 1:
895    img3 = (img * 255.0).astype(numpy.uint8).repeat(3).reshape(h, w, 3)
896    Image.fromarray(img3, 'RGB').save(fname)
897  else:
898    raise error_util.CameraItsError('Unsupported image type')
899
900
901def read_image(fname):
902  """Read image function to match write_image() above."""
903  return Image.open(fname)
904
905
906def apply_lut_to_image(img, lut):
907  """Applies a LUT to every pixel in a float image array.
908
909  Internally converts to a 16b integer image, since the LUT can work with up
910  to 16b->16b mappings (i.e. values in the range [0,65535]). The lut can also
911  have fewer than 65536 entries, however it must be sized as a power of 2
912  (and for smaller luts, the scale must match the bitdepth).
913
914  For a 16b lut of 65536 entries, the operation performed is:
915
916  lut[r * 65535] / 65535 -> r'
917  lut[g * 65535] / 65535 -> g'
918  lut[b * 65535] / 65535 -> b'
919
920  For a 10b lut of 1024 entries, the operation becomes:
921
922  lut[r * 1023] / 1023 -> r'
923  lut[g * 1023] / 1023 -> g'
924  lut[b * 1023] / 1023 -> b'
925
926  Args:
927    img: Numpy float image array, with pixel values in [0,1].
928    lut: Numpy table encoding a LUT, mapping 16b integer values.
929
930  Returns:
931    Float image array after applying LUT to each pixel.
932  """
933  n = len(lut)
934  if n <= 0 or n > MAX_LUT_SIZE or (n & (n - 1)) != 0:
935    raise error_util.CameraItsError(f'Invalid arg LUT size: {n}')
936  m = float(n - 1)
937  return (lut[(img * m).astype(numpy.uint16)] / m).astype(numpy.float32)
938
939
940def get_gains_in_canonical_order(props, gains):
941  """Reorders the gains tuple to the canonical R,Gr,Gb,B order.
942
943  Args:
944    props: Camera properties object.
945    gains: List of 4 values, in R,G_even,G_odd,B order.
946
947  Returns:
948    List of gains values, in R,Gr,Gb,B order.
949  """
950  cfa_pat = props['android.sensor.info.colorFilterArrangement']
951  if cfa_pat in [0, 1]:
952    # RGGB or GRBG, so G_even is Gr
953    return gains
954  elif cfa_pat in [2, 3]:
955    # GBRG or BGGR, so G_even is Gb
956    return [gains[0], gains[2], gains[1], gains[3]]
957  else:
958    raise error_util.CameraItsError('Not supported')
959
960
961def get_white_level(props, cap_metadata=None):
962  """Gets white level to use for a given capture.
963
964  Uses a dynamic value from the capture result if available, else falls back
965  to the static global value in the camera characteristics.
966
967  Args:
968    props: The camera properties object.
969    cap_metadata: A capture results metadata object.
970
971  Returns:
972    Float white level value.
973  """
974  if (cap_metadata is not None and
975      'android.sensor.dynamicWhiteLevel' in cap_metadata and
976      cap_metadata['android.sensor.dynamicWhiteLevel'] is not None):
977    white_level = cap_metadata['android.sensor.dynamicWhiteLevel']
978    logging.debug('dynamic white level: %.2f', white_level)
979  else:
980    white_level = props['android.sensor.info.whiteLevel']
981    logging.debug('white level: %.2f', white_level)
982  return float(white_level)
983
984
985def get_black_levels(props, cap=None, is_quad_bayer=False):
986  """Gets black levels to use for a given capture.
987
988  Uses a dynamic value from the capture result if available, else falls back
989  to the static global value in the camera characteristics.
990
991  Args:
992    props: The camera properties object.
993    cap: A capture object.
994    is_quad_bayer: Boolean flag for Bayer or Quad Bayer capture.
995
996  Returns:
997    A list of black level values reordered in canonical order.
998  """
999  if (cap is not None and
1000      'android.sensor.dynamicBlackLevel' in cap and
1001      cap['android.sensor.dynamicBlackLevel'] is not None):
1002    black_levels = cap['android.sensor.dynamicBlackLevel']
1003  else:
1004    black_levels = props['android.sensor.blackLevelPattern']
1005
1006  idxs = get_canonical_cfa_order(props, is_quad_bayer)
1007  if is_quad_bayer:
1008    ordered_black_levels = [black_levels[i // 4] for i in idxs]
1009  else:
1010    ordered_black_levels = [black_levels[i] for i in idxs]
1011  return ordered_black_levels
1012
1013
1014def get_canonical_cfa_order(props, is_quad_bayer=False):
1015  """Returns a list of channel indices according to color filter arrangement.
1016
1017  Color filter arrangement index is a integer ranging from 0 to 3, which maps
1018  the color filter arrangement in the following way.
1019    0: R, Gr, Gb, B,
1020    1: Gr, R, B, Gb,
1021    2: Gb, B, R, Gr,
1022    3: B, Gb, Gr, R.
1023
1024  This function return a list of channel indices that can be used to reorder
1025  the stats data as the canonical order:
1026    (1) For standard Bayer: R, Gr, Gb, B.
1027    (2) For quad Bayer: R0, R1, R2, R3,
1028                        Gr0, Gr1, Gr2, Gr3,
1029                        Gb0, Gb1, Gb2, Gb3,
1030                        B0, B1, B2, B3.
1031
1032  Args:
1033    props: Camera properties object.
1034    is_quad_bayer: Boolean flag for Bayer or Quad Bayer capture.
1035
1036  Returns:
1037    A list of channel indices with values ranging from:
1038      (1) [0, 3] for standard Bayer,
1039      (2) [0, 15] for quad Bayer.
1040  """
1041  cfa_pat = props['android.sensor.info.colorFilterArrangement']
1042  if not 0 <= cfa_pat < 4:
1043    raise error_util.CameraItsError('Not supported')
1044
1045  channel_indices = []
1046  if is_quad_bayer:
1047    color_map = noise_model_constants.QUAD_BAYER_COLOR_FILTER_MAP[cfa_pat]
1048    for ch in noise_model_constants.BAYER_COLORS:
1049      channel_indices.extend(color_map[ch])
1050  else:
1051    color_map = noise_model_constants.BAYER_COLOR_FILTER_MAP[cfa_pat]
1052    channel_indices = [
1053        color_map[ch] for ch in noise_model_constants.BAYER_COLORS
1054    ]
1055  return channel_indices
1056
1057
1058def unpack_rawstats_capture(cap, num_channels=4):
1059  """Unpacks a stats image capture to the mean and variance images.
1060
1061  Args:
1062    cap: A capture object as returned by its_session_utils.do_capture.
1063    num_channels: The number of color channels in the stats image capture, which
1064      can be one of noise_model_constants.VALID_NUM_CHANNELS.
1065
1066  Returns:
1067    Tuple (mean_image var_image) of float-4 images, with non-normalized
1068    pixel values computed from the RAW10/RAW16 images on the device
1069  """
1070  if cap['format'] not in noise_model_constants.VALID_RAW_STATS_FORMATS:
1071    raise AssertionError(f"Unsupported stats format: {cap['format']}")
1072
1073  if num_channels not in noise_model_constants.VALID_NUM_CHANNELS:
1074    raise AssertionError(
1075        f'Unsupported number of channels {num_channels}, which should be in'
1076        f' {noise_model_constants.VALID_NUM_CHANNELS}.'
1077    )
1078
1079  w = cap['width']
1080  h = cap['height']
1081  img = numpy.ndarray(
1082      shape=(2 * h * w * num_channels,), dtype='<f', buffer=cap['data']
1083  )
1084  analysis_image = img.reshape((2, h, w, num_channels))
1085  mean_image = analysis_image[0, :, :, :].reshape(h, w, num_channels)
1086  var_image = analysis_image[1, :, :, :].reshape(h, w, num_channels)
1087  return mean_image, var_image
1088
1089
1090def get_image_patch(img, xnorm, ynorm, wnorm, hnorm):
1091  """Get a patch (tile) of an image.
1092
1093  Args:
1094   img: Numpy float image array, with pixel values in [0,1].
1095   xnorm:
1096   ynorm:
1097   wnorm:
1098   hnorm: Normalized (in [0,1]) coords for the tile.
1099
1100  Returns:
1101     Numpy float image array of the patch.
1102  """
1103  hfull = img.shape[0]
1104  wfull = img.shape[1]
1105  xtile = int(math.ceil(xnorm * wfull))
1106  ytile = int(math.ceil(ynorm * hfull))
1107  wtile = int(math.floor(wnorm * wfull))
1108  htile = int(math.floor(hnorm * hfull))
1109  if len(img.shape) == 2:
1110    return img[ytile:ytile + htile, xtile:xtile + wtile].copy()
1111  else:
1112    return img[ytile:ytile + htile, xtile:xtile + wtile, :].copy()
1113
1114
1115def compute_image_means(img):
1116  """Calculate the mean of each color channel in the image.
1117
1118  Args:
1119    img: Numpy float image array, with pixel values in [0,1].
1120
1121  Returns:
1122     A list of mean values, one per color channel in the image.
1123  """
1124  means = []
1125  chans = img.shape[2]
1126  for i in range(chans):
1127    means.append(numpy.mean(img[:, :, i], dtype=numpy.float64))
1128  return means
1129
1130
1131def compute_image_variances(img):
1132  """Calculate the variance of each color channel in the image.
1133
1134  Args:
1135    img: Numpy float image array, with pixel values in [0,1].
1136
1137  Returns:
1138    A list of variance values, one per color channel in the image.
1139  """
1140  variances = []
1141  chans = img.shape[2]
1142  for i in range(chans):
1143    variances.append(numpy.var(img[:, :, i], dtype=numpy.float64))
1144  return variances
1145
1146
1147def compute_image_sharpness(img):
1148  """Calculate the sharpness of input image.
1149
1150  Args:
1151    img: numpy float RGB/luma image array, with pixel values in [0,1].
1152
1153  Returns:
1154    Sharpness estimation value based on the average of gradient magnitude.
1155    Larger value means the image is sharper.
1156  """
1157  chans = img.shape[2]
1158  if chans != 1 and chans != 3:
1159    raise AssertionError(f'Not RGB or MONO image! depth: {chans}')
1160  if chans == 1:
1161    luma = img[:, :, 0]
1162  else:
1163    luma = convert_rgb_to_grayscale(img)
1164  gy, gx = numpy.gradient(luma)
1165  return numpy.average(numpy.sqrt(gy*gy + gx*gx))
1166
1167
1168def compute_image_max_gradients(img):
1169  """Calculate the maximum gradient of each color channel in the image.
1170
1171  Args:
1172    img: Numpy float image array, with pixel values in [0,1].
1173
1174  Returns:
1175    A list of gradient max values, one per color channel in the image.
1176  """
1177  grads = []
1178  chans = img.shape[2]
1179  for i in range(chans):
1180    grads.append(numpy.amax(numpy.gradient(img[:, :, i])))
1181  return grads
1182
1183
1184def compute_image_snrs(img):
1185  """Calculate the SNR (dB) of each color channel in the image.
1186
1187  Args:
1188    img: Numpy float image array, with pixel values in [0,1].
1189
1190  Returns:
1191    A list of SNR values in dB, one per color channel in the image.
1192  """
1193  means = compute_image_means(img)
1194  variances = compute_image_variances(img)
1195  std_devs = [math.sqrt(v) for v in variances]
1196  snrs = [20 * math.log10(m/s) for m, s in zip(means, std_devs)]
1197  return snrs
1198
1199
1200def convert_rgb_to_grayscale(img):
1201  """Convert a 3-D array RGB image to grayscale image.
1202
1203  Args:
1204    img: numpy 3-D array RGB image of type [0.0, 1.0] float or [0, 255] uint8.
1205
1206  Returns:
1207    2-D grayscale image of same type as input.
1208  """
1209  chans = img.shape[2]
1210  if chans != 3:
1211    raise AssertionError(f'Not an RGB image! Depth: {chans}')
1212  img_gray = numpy.dot(img[..., :3], RGB2GRAY_WEIGHTS)
1213  if img.dtype == 'uint8':
1214    return img_gray.round().astype(numpy.uint8)
1215  else:
1216    return img_gray
1217
1218
1219def normalize_img(img):
1220  """Normalize the image values to between 0 and 1.
1221
1222  Args:
1223    img: 2-D numpy array of image values
1224  Returns:
1225    Normalized image
1226  """
1227  return (img - numpy.amin(img))/(numpy.amax(img) - numpy.amin(img))
1228
1229
1230def rotate_img_per_argv(img):
1231  """Rotate an image 180 degrees if "rotate" is in argv.
1232
1233  Args:
1234    img: 2-D numpy array of image values
1235  Returns:
1236    Rotated image
1237  """
1238  img_out = img
1239  if 'rotate180' in sys.argv:
1240    img_out = numpy.fliplr(numpy.flipud(img_out))
1241  return img_out
1242
1243
1244def compute_image_rms_difference_1d(rgb_x, rgb_y):
1245  """Calculate the RMS difference between 2 RBG images as 1D arrays.
1246
1247  Args:
1248    rgb_x: image array
1249    rgb_y: image array
1250
1251  Returns:
1252    rms_diff
1253  """
1254  len_rgb_x = len(rgb_x)
1255  len_rgb_y = len(rgb_y)
1256  if len_rgb_y != len_rgb_x:
1257    raise AssertionError('RGB images have different number of planes! '
1258                         f'x: {len_rgb_x}, y: {len_rgb_y}')
1259  return math.sqrt(sum([pow(rgb_x[i] - rgb_y[i], 2.0)
1260                        for i in range(len_rgb_x)]) / len_rgb_x)
1261
1262
1263def compute_image_rms_difference_3d(rgb_x, rgb_y):
1264  """Calculate the RMS difference between 2 RBG images as 3D arrays.
1265
1266  Args:
1267    rgb_x: image array in the form of w * h * channels
1268    rgb_y: image array in the form of w * h * channels
1269
1270  Returns:
1271    rms_diff
1272  """
1273  shape_rgb_x = numpy.shape(rgb_x)
1274  shape_rgb_y = numpy.shape(rgb_y)
1275  if shape_rgb_y != shape_rgb_x:
1276    raise AssertionError('RGB images have different number of planes! '
1277                         f'x: {shape_rgb_x}, y: {shape_rgb_y}')
1278  if len(shape_rgb_x) != 3:
1279    raise AssertionError(f'RGB images dimension {len(shape_rgb_x)} is not 3!')
1280
1281  mean_square_sum = 0.0
1282  for i in range(shape_rgb_x[0]):
1283    for j in range(shape_rgb_x[1]):
1284      for k in range(shape_rgb_x[2]):
1285        mean_square_sum += pow(float(rgb_x[i][j][k]) - float(rgb_y[i][j][k]),
1286                               2.0)
1287  return (math.sqrt(mean_square_sum /
1288                    (shape_rgb_x[0] * shape_rgb_x[1] * shape_rgb_x[2])))
1289
1290
1291def compute_image_sad(img_x, img_y):
1292  """Calculate the sum of absolute differences between 2 images.
1293
1294  Args:
1295    img_x: image array in the form of w * h * channels
1296    img_y: image array in the form of w * h * channels
1297
1298  Returns:
1299    sad
1300  """
1301  img_x = img_x[:, :, 1:].ravel()
1302  img_y = img_y[:, :, 1:].ravel()
1303  return numpy.sum(numpy.abs(numpy.subtract(img_x, img_y, dtype=float)))
1304
1305
1306def get_img(buffer):
1307  """Return a PIL.Image of the capture buffer.
1308
1309  Args:
1310    buffer: data field from the capture result.
1311
1312  Returns:
1313    A PIL.Image
1314  """
1315  return Image.open(io.BytesIO(buffer))
1316
1317
1318def jpeg_has_icc_profile(jpeg_img):
1319  """Checks if a jpeg PIL.Image has an icc profile attached.
1320
1321  Args:
1322    jpeg_img: The PIL.Image.
1323
1324  Returns:
1325    True if an icc profile is present, False otherwise.
1326  """
1327  return jpeg_img.info.get('icc_profile') is not None
1328
1329
1330def get_primary_chromaticity(primary):
1331  """Given an ImageCms primary, returns just the xy chromaticity coordinates.
1332
1333  Args:
1334    primary: The primary from the ImageCms profile.
1335
1336  Returns:
1337    (float, float): The xy chromaticity coordinates of the primary.
1338  """
1339  ((_, _, _), (x, y, _)) = primary
1340  return x, y
1341
1342
1343def is_jpeg_icc_profile_correct(jpeg_img, color_space, icc_profile_path=None):
1344  """Compare a jpeg's icc profile to a color space's expected parameters.
1345
1346  Args:
1347    jpeg_img: The PIL.Image.
1348    color_space: 'DISPLAY_P3' or 'SRGB'
1349    icc_profile_path: Optional path to an icc file to be created with the
1350        raw contents.
1351
1352  Returns:
1353    True if the icc profile matches expectations, False otherwise.
1354  """
1355  icc = jpeg_img.info.get('icc_profile')
1356  f = io.BytesIO(icc)
1357  icc_profile = ImageCms.getOpenProfile(f)
1358
1359  if icc_profile_path is not None:
1360    raw_icc_bytes = f.getvalue()
1361    f = open(icc_profile_path, 'wb')
1362    f.write(raw_icc_bytes)
1363    f.close()
1364
1365  cms_profile = icc_profile.profile
1366  (rx, ry) = get_primary_chromaticity(cms_profile.red_primary)
1367  (gx, gy) = get_primary_chromaticity(cms_profile.green_primary)
1368  (bx, by) = get_primary_chromaticity(cms_profile.blue_primary)
1369
1370  if color_space == 'DISPLAY_P3':
1371    # Expected primaries based on Apple's Display P3 primaries
1372    expected_rx = EXPECTED_RX_P3
1373    expected_ry = EXPECTED_RY_P3
1374    expected_gx = EXPECTED_GX_P3
1375    expected_gy = EXPECTED_GY_P3
1376    expected_bx = EXPECTED_BX_P3
1377    expected_by = EXPECTED_BY_P3
1378  elif color_space == 'SRGB':
1379    # Expected primaries based on Pixel sRGB profile
1380    expected_rx = EXPECTED_RX_SRGB
1381    expected_ry = EXPECTED_RY_SRGB
1382    expected_gx = EXPECTED_GX_SRGB
1383    expected_gy = EXPECTED_GY_SRGB
1384    expected_bx = EXPECTED_BX_SRGB
1385    expected_by = EXPECTED_BY_SRGB
1386  else:
1387    # Unsupported color space for comparison
1388    return False
1389
1390  cmp_values = [
1391      [rx, expected_rx],
1392      [ry, expected_ry],
1393      [gx, expected_gx],
1394      [gy, expected_gy],
1395      [bx, expected_bx],
1396      [by, expected_by]
1397  ]
1398
1399  for (actual, expected) in cmp_values:
1400    if not math.isclose(actual, expected, abs_tol=0.001):
1401      # Values significantly differ
1402      return False
1403
1404  return True
1405
1406
1407def area_of_triangle(x1, y1, x2, y2, x3, y3):
1408  """Calculates the area of a triangle formed by three points.
1409
1410  Args:
1411    x1 (float): The x-coordinate of the first point.
1412    y1 (float): The y-coordinate of the first point.
1413    x2 (float): The x-coordinate of the second point.
1414    y2 (float): The y-coordinate of the second point.
1415    x3 (float): The x-coordinate of the third point.
1416    y3 (float): The y-coordinate of the third point.
1417
1418  Returns:
1419    float: The area of the triangle.
1420  """
1421  area = abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2.0)
1422  return area
1423
1424
1425def point_in_triangle(x1, y1, x2, y2, x3, y3, xp, yp, abs_tol):
1426  """Checks if the point (xp, yp) is inside the triangle.
1427
1428  Args:
1429    x1 (float): The x-coordinate of the first point.
1430    y1 (float): The y-coordinate of the first point.
1431    x2 (float): The x-coordinate of the second point.
1432    y2 (float): The y-coordinate of the second point.
1433    x3 (float): The x-coordinate of the third point.
1434    y3 (float): The y-coordinate of the third point.
1435    xp (float): The x-coordinate of the point to check.
1436    yp (float): The y-coordinate of the point to check.
1437    abs_tol (float): Absolute tolerance amount.
1438
1439  Returns:
1440    bool: True if the point is inside the triangle, False otherwise.
1441  """
1442  a = area_of_triangle(x1, y1, x2, y2, x3, y3)
1443  a1 = area_of_triangle(xp, yp, x2, y2, x3, y3)
1444  a2 = area_of_triangle(x1, y1, xp, yp, x3, y3)
1445  a3 = area_of_triangle(x1, y1, x2, y2, xp, yp)
1446  return math.isclose(a, (a1 + a2 + a3), abs_tol=abs_tol)
1447
1448
1449def distance(p, q):
1450  """Returns the Euclidean distance from point p to point q.
1451
1452  Args:
1453    p: an Iterable of numbers
1454    q: an Iterable of numbers
1455  """
1456  return math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(p, q)))
1457
1458
1459def srgb_eotf(img):
1460  """Returns the input sRGB-transferred image with a linear transfer function.
1461
1462  Args:
1463    img: The input image as a numpy array.
1464
1465  Returns:
1466    numpy.array: The same image with a linear transfer.
1467  """
1468
1469  # Source:
1470  # https://developer.android.com/reference/android/graphics/ColorSpace.Named#DISPLAY_P3
1471  return numpy.where(
1472      img < 0.04045,
1473      img / 12.92,
1474      numpy.pow((img + 0.055) / 1.055, 2.4)
1475  )
1476
1477
1478def ciexyz_to_xy(img):
1479  """Returns the input CIE XYZ image in the CIE xy colorspace.
1480
1481  Args:
1482    img: The input image as a numpy array
1483
1484  Returns:
1485    numpy.array: The same image in the CIE xy colorspace.
1486  """
1487  img_sums = img.sum(axis=2)
1488  img_sums[img_sums == 0] = 1
1489  img[:, :, 0] = img[:, :, 0] / img_sums
1490  img[:, :, 1] = img[:, :, 1] / img_sums
1491  return img[:, :, :2]
1492
1493
1494def p3_img_has_wide_gamut(wide_img):
1495  """Check if a DISPLAY_P3 image contains wide gamut pixels.
1496
1497  Given a DISPLAY_P3 image that should have a wider gamut than SRGB, checks all
1498  pixel values to see if any reside outside the SRGB gamut. This is done by
1499  converting to CIE xy chromaticities using a Bradford chromatic adaptation for
1500  consistency with ICC profiles.
1501
1502  Args:
1503    wide_img: The PIL.Image in the DISPLAY_P3 color space.
1504
1505  Returns:
1506    True if the gamut of wide_img is greater than that of SRGB.
1507    False otherwise.
1508  """
1509  w = wide_img.size[0]
1510  h = wide_img.size[1]
1511  wide_arr = numpy.array(wide_img)
1512  linear_arr = srgb_eotf(wide_arr / float(numpy.iinfo(numpy.uint8).max))
1513
1514  xyz_arr = numpy.matmul(linear_arr, P3_TO_XYZ)
1515  xy_arr = ciexyz_to_xy(xyz_arr)
1516
1517  for y in range(h):
1518    for x in range(w):
1519      # Check if the pixel chromaticity is inside or outside the SRGB gamut.
1520      # This check is not guaranteed not to emit false positives / negatives,
1521      # however the probability of either on an arbitrary DISPLAY_P3 camera
1522      # capture is exceedingly unlikely.
1523      if not point_in_triangle(x1=EXPECTED_RX_SRGB, y1=EXPECTED_RY_SRGB,
1524                               x2=EXPECTED_GX_SRGB, y2=EXPECTED_GY_SRGB,
1525                               x3=EXPECTED_BX_SRGB, y3=EXPECTED_BY_SRGB,
1526                               xp=xy_arr[y][x][0], yp=xy_arr[y][x][1],
1527                               abs_tol=COLORSPACE_TRIANGLE_AREA_TOL):
1528        return True
1529
1530  return False
1531
1532
1533def compute_patch_noise(yuv_img, patch_region):
1534  """Computes the noise statistics of a flat patch region in an image.
1535
1536  For the patch region, the noise statistics are computed for the luma, chroma
1537  U, and chroma V channels.
1538
1539  Args:
1540    yuv_img: The openCV YUV image to compute noise statistics for.
1541    patch_region: The (x, y, w, h) region to compute noise statistics for.
1542  Returns:
1543    A dictionary of noise statistics with keys luma, chroma_u, chroma_v.
1544  """
1545  x, y, w, h = patch_region
1546  patch = yuv_img[y : y + h, x : x + w]
1547  return {
1548      'luma': numpy.std(patch[:, :, 0]),
1549      'chroma_u': numpy.std(patch[:, :, 1]),
1550      'chroma_v': numpy.std(patch[:, :, 2]),
1551  }
1552
1553
1554def convert_image_coords_to_sensor_coords(
1555    aa_width, aa_height, coords, img_width, img_height):
1556  """Transform image coordinates to sensor coordinate system.
1557
1558  Calculate the difference between sensor active array and image aspect ratio.
1559  Taking the difference into account, figure out if the width or height has been
1560  cropped. Using this information, transform the image coordinates to sensor
1561  coordinates.
1562
1563  Args:
1564    aa_width: int; active array width.
1565    aa_height: int; active array height.
1566    coords: coordinates; a pair of (x, y) coordinates from image.
1567    img_width: int; width of image.
1568    img_height: int; height of image.
1569  Returns:
1570    sensor_coords: coordinates; corresponding coordinates on
1571      sensor coordinate system.
1572  """
1573  # TODO: b/330382627 - find out if distortion correction is ON/OFF
1574  aa_aspect_ratio = aa_width / aa_height
1575  image_aspect_ratio = img_width / img_height
1576  if aa_aspect_ratio >= image_aspect_ratio:
1577    # If aa aspect ratio is greater than image aspect ratio, then
1578    # sensor width is being cropped
1579    aspect_ratio_multiplication_factor = aa_height / img_height
1580    crop_width = img_width * aspect_ratio_multiplication_factor
1581    buffer = (aa_width - crop_width) / 2
1582    sensor_coords = (coords[0] * aspect_ratio_multiplication_factor + buffer,
1583                     coords[1] * aspect_ratio_multiplication_factor)
1584  else:
1585    # If aa aspect ratio is less than image aspect ratio, then
1586    # sensor height is being cropped
1587    aspect_ratio_multiplication_factor = aa_width / img_width
1588    crop_height = img_height * aspect_ratio_multiplication_factor
1589    buffer = (aa_height - crop_height) / 2
1590    sensor_coords = (coords[0] * aspect_ratio_multiplication_factor,
1591                     coords[1] * aspect_ratio_multiplication_factor + buffer)
1592  logging.debug('Sensor coordinates: %s', sensor_coords)
1593  return sensor_coords
1594
1595
1596def convert_sensor_coords_to_image_coords(
1597    aa_width, aa_height, coords, img_width, img_height):
1598  """Transform sensor coordinates to image coordinate system.
1599
1600  Calculate the difference between sensor active array and image aspect ratio.
1601  Taking the difference into account, figure out if the width or height has been
1602  cropped. Using this information, transform the sensor coordinates to image
1603  coordinates.
1604
1605  Args:
1606    aa_width: int; active array width.
1607    aa_height: int; active array height.
1608    coords: coordinates; a pair of (x, y) coordinates from sensor.
1609    img_width: int; width of image.
1610    img_height: int; height of image.
1611  Returns:
1612    image_coords: coordinates; corresponding coordinates on
1613      image coordinate system.
1614  """
1615  aa_aspect_ratio = aa_width / aa_height
1616  image_aspect_ratio = img_width / img_height
1617  if aa_aspect_ratio >= image_aspect_ratio:
1618    # If aa aspect ratio is greater than image aspect ratio, then
1619    # sensor width is being cropped
1620    aspect_ratio_multiplication_factor = aa_height / img_height
1621    crop_width = img_width * aspect_ratio_multiplication_factor
1622    buffer = (aa_width - crop_width) / 2
1623    image_coords = (
1624        (coords[0] - buffer) / aspect_ratio_multiplication_factor,
1625        coords[1] / aspect_ratio_multiplication_factor)
1626  else:
1627    # If aa aspect ratio is less than image aspect ratio, then
1628    # sensor height is being cropped
1629    aspect_ratio_multiplication_factor = aa_width / img_width
1630    crop_height = img_height * aspect_ratio_multiplication_factor
1631    buffer = (aa_height - crop_height) / 2
1632    image_coords = (
1633        coords[0] / aspect_ratio_multiplication_factor,
1634        (coords[1] - buffer) / aspect_ratio_multiplication_factor)
1635  logging.debug('Image coordinates: %s', image_coords)
1636  return image_coords
1637