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