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