# Copyright 2013 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Image processing utility functions.""" import copy import io import logging import math import os import random import sys import unittest import numpy from PIL import Image import cv2 import capture_request_utils import error_util # The matrix is from JFIF spec DEFAULT_YUV_TO_RGB_CCM = numpy.matrix([[1.000, 0.000, 1.402], [1.000, -0.344, -0.714], [1.000, 1.772, 0.000]]) DEFAULT_YUV_OFFSETS = numpy.array([0, 128, 128]) MAX_LUT_SIZE = 65536 DEFAULT_GAMMA_LUT = numpy.array([ math.floor((MAX_LUT_SIZE-1) * math.pow(i/(MAX_LUT_SIZE-1), 1/2.2) + 0.5) for i in range(MAX_LUT_SIZE)]) NUM_TRIES = 2 NUM_FRAMES = 4 TEST_IMG_DIR = os.path.join(os.environ['CAMERA_ITS_TOP'], 'test_images') # pylint: disable=unused-argument def convert_capture_to_rgb_image(cap, ccm_yuv_to_rgb=DEFAULT_YUV_TO_RGB_CCM, yuv_off=DEFAULT_YUV_OFFSETS, props=None): """Convert a captured image object to a RGB image. Args: cap: A capture object as returned by its_session_utils.do_capture. ccm_yuv_to_rgb: (Optional) the 3x3 CCM to convert from YUV to RGB. yuv_off: (Optional) offsets to subtract from each of Y,U,V values. props: (Optional) camera properties object (of static values); required for processing raw images. Returns: RGB float-3 image array, with pixel values in [0.0, 1.0]. """ w = cap['width'] h = cap['height'] if cap['format'] == 'raw10': assert props is not None cap = unpack_raw10_capture(cap) if cap['format'] == 'raw12': assert props is not None cap = unpack_raw12_capture(cap) if cap['format'] == 'yuv': y = cap['data'][0: w * h] u = cap['data'][w * h: w * h * 5//4] v = cap['data'][w * h * 5//4: w * h * 6//4] return convert_yuv420_planar_to_rgb_image(y, u, v, w, h) elif cap['format'] == 'jpeg': return decompress_jpeg_to_rgb_image(cap['data']) elif cap['format'] == 'raw' or cap['format'] == 'rawStats': assert props is not None r, gr, gb, b = convert_capture_to_planes(cap, props) return convert_raw_to_rgb_image(r, gr, gb, b, props, cap['metadata']) elif cap['format'] == 'y8': y = cap['data'][0: w * h] return convert_y8_to_rgb_image(y, w, h) else: raise error_util.CameraItsError('Invalid format %s' % (cap['format'])) def unpack_raw10_capture(cap): """Unpack a raw-10 capture to a raw-16 capture. Args: cap: A raw-10 capture object. Returns: New capture object with raw-16 data. """ # Data is packed as 4x10b pixels in 5 bytes, with the first 4 bytes holding # the MSBs of the pixels, and the 5th byte holding 4x2b LSBs. w, h = cap['width'], cap['height'] if w % 4 != 0: raise error_util.CameraItsError('Invalid raw-10 buffer width') cap = copy.deepcopy(cap) cap['data'] = unpack_raw10_image(cap['data'].reshape(h, w * 5 // 4)) cap['format'] = 'raw' return cap def unpack_raw10_image(img): """Unpack a raw-10 image to a raw-16 image. Output image will have the 10 LSBs filled in each 16b word, and the 6 MSBs will be set to zero. Args: img: A raw-10 image, as a uint8 numpy array. Returns: Image as a uint16 numpy array, with all row padding stripped. """ if img.shape[1] % 5 != 0: raise error_util.CameraItsError('Invalid raw-10 buffer width') w = img.shape[1] * 4 // 5 h = img.shape[0] # Cut out the 4x8b MSBs and shift to bits [9:2] in 16b words. msbs = numpy.delete(img, numpy.s_[4::5], 1) msbs = msbs.astype(numpy.uint16) msbs = numpy.left_shift(msbs, 2) msbs = msbs.reshape(h, w) # Cut out the 4x2b LSBs and put each in bits [1:0] of their own 8b words. lsbs = img[::, 4::5].reshape(h, w // 4) lsbs = numpy.right_shift( numpy.packbits(numpy.unpackbits(lsbs).reshape(h, w // 4, 4, 2), 3), 6) # Pair the LSB bits group to 0th pixel instead of 3rd pixel lsbs = lsbs.reshape(h, w // 4, 4)[:, :, ::-1] lsbs = lsbs.reshape(h, w) # Fuse the MSBs and LSBs back together img16 = numpy.bitwise_or(msbs, lsbs).reshape(h, w) return img16 def unpack_raw12_capture(cap): """Unpack a raw-12 capture to a raw-16 capture. Args: cap: A raw-12 capture object. Returns: New capture object with raw-16 data. """ # Data is packed as 4x10b pixels in 5 bytes, with the first 4 bytes holding # the MSBs of the pixels, and the 5th byte holding 4x2b LSBs. w, h = cap['width'], cap['height'] if w % 2 != 0: raise error_util.CameraItsError('Invalid raw-12 buffer width') cap = copy.deepcopy(cap) cap['data'] = unpack_raw12_image(cap['data'].reshape(h, w * 3 // 2)) cap['format'] = 'raw' return cap def unpack_raw12_image(img): """Unpack a raw-12 image to a raw-16 image. Output image will have the 12 LSBs filled in each 16b word, and the 4 MSBs will be set to zero. Args: img: A raw-12 image, as a uint8 numpy array. Returns: Image as a uint16 numpy array, with all row padding stripped. """ if img.shape[1] % 3 != 0: raise error_util.CameraItsError('Invalid raw-12 buffer width') w = img.shape[1] * 2 // 3 h = img.shape[0] # Cut out the 2x8b MSBs and shift to bits [11:4] in 16b words. msbs = numpy.delete(img, numpy.s_[2::3], 1) msbs = msbs.astype(numpy.uint16) msbs = numpy.left_shift(msbs, 4) msbs = msbs.reshape(h, w) # Cut out the 2x4b LSBs and put each in bits [3:0] of their own 8b words. lsbs = img[::, 2::3].reshape(h, w // 2) lsbs = numpy.right_shift( numpy.packbits(numpy.unpackbits(lsbs).reshape(h, w // 2, 2, 4), 3), 4) # Pair the LSB bits group to pixel 0 instead of pixel 1 lsbs = lsbs.reshape(h, w // 2, 2)[:, :, ::-1] lsbs = lsbs.reshape(h, w) # Fuse the MSBs and LSBs back together img16 = numpy.bitwise_or(msbs, lsbs).reshape(h, w) return img16 def convert_yuv420_planar_to_rgb_image(y_plane, u_plane, v_plane, w, h, ccm_yuv_to_rgb=DEFAULT_YUV_TO_RGB_CCM, yuv_off=DEFAULT_YUV_OFFSETS): """Convert a YUV420 8-bit planar image to an RGB image. Args: y_plane: The packed 8-bit Y plane. u_plane: The packed 8-bit U plane. v_plane: The packed 8-bit V plane. w: The width of the image. h: The height of the image. ccm_yuv_to_rgb: (Optional) the 3x3 CCM to convert from YUV to RGB. yuv_off: (Optional) offsets to subtract from each of Y,U,V values. Returns: RGB float-3 image array, with pixel values in [0.0, 1.0]. """ y = numpy.subtract(y_plane, yuv_off[0]) u = numpy.subtract(u_plane, yuv_off[1]).view(numpy.int8) v = numpy.subtract(v_plane, yuv_off[2]).view(numpy.int8) u = u.reshape(h // 2, w // 2).repeat(2, axis=1).repeat(2, axis=0) v = v.reshape(h // 2, w // 2).repeat(2, axis=1).repeat(2, axis=0) yuv = numpy.dstack([y, u.reshape(w * h), v.reshape(w * h)]) flt = numpy.empty([h, w, 3], dtype=numpy.float32) flt.reshape(w * h * 3)[:] = yuv.reshape(h * w * 3)[:] flt = numpy.dot(flt.reshape(w * h, 3), ccm_yuv_to_rgb.T).clip(0, 255) rgb = numpy.empty([h, w, 3], dtype=numpy.uint8) rgb.reshape(w * h * 3)[:] = flt.reshape(w * h * 3)[:] return rgb.astype(numpy.float32) / 255.0 def decompress_jpeg_to_rgb_image(jpeg_buffer): """Decompress a JPEG-compressed image, returning as an RGB image. Args: jpeg_buffer: The JPEG stream. Returns: A numpy array for the RGB image, with pixels in [0,1]. """ img = Image.open(io.BytesIO(jpeg_buffer)) w = img.size[0] h = img.size[1] return numpy.array(img).reshape(h, w, 3) / 255.0 def convert_capture_to_planes(cap, props=None): """Convert a captured image object to separate image planes. Decompose an image into multiple images, corresponding to different planes. For YUV420 captures ("yuv"): Returns Y,U,V planes, where the Y plane is full-res and the U,V planes are each 1/2 x 1/2 of the full res. For Bayer captures ("raw", "raw10", "raw12", or "rawStats"): Returns planes in the order R,Gr,Gb,B, regardless of the Bayer pattern layout. For full-res raw images ("raw", "raw10", "raw12"), each plane is 1/2 x 1/2 of the full res. For "rawStats" images, the mean image is returned. For JPEG captures ("jpeg"): Returns R,G,B full-res planes. Args: cap: A capture object as returned by its_session_utils.do_capture. props: (Optional) camera properties object (of static values); required for processing raw images. Returns: A tuple of float numpy arrays (one per plane), consisting of pixel values in the range [0.0, 1.0]. """ w = cap['width'] h = cap['height'] if cap['format'] == 'raw10': assert props is not None cap = unpack_raw10_capture(cap) if cap['format'] == 'raw12': assert props is not None cap = unpack_raw12_capture(cap) if cap['format'] == 'yuv': y = cap['data'][0:w * h] u = cap['data'][w * h:w * h * 5 // 4] v = cap['data'][w * h * 5 // 4:w * h * 6 // 4] return ((y.astype(numpy.float32) / 255.0).reshape(h, w, 1), (u.astype(numpy.float32) / 255.0).reshape(h // 2, w // 2, 1), (v.astype(numpy.float32) / 255.0).reshape(h // 2, w // 2, 1)) elif cap['format'] == 'jpeg': rgb = decompress_jpeg_to_rgb_image(cap['data']).reshape(w * h * 3) return (rgb[::3].reshape(h, w, 1), rgb[1::3].reshape(h, w, 1), rgb[2::3].reshape(h, w, 1)) elif cap['format'] == 'raw': assert props is not None white_level = float(props['android.sensor.info.whiteLevel']) img = numpy.ndarray( shape=(h * w,), dtype='= wcrop >= 0 assert hfull >= hcrop >= 0 assert wfull - wcrop >= xcrop >= 0 assert hfull - hcrop >= ycrop >= 0 if w == wfull and h == hfull: # Crop needed; extract the center region. img = img[ycrop:ycrop + hcrop, xcrop:xcrop + wcrop] w = wcrop h = hcrop elif w == wcrop and h == hcrop: logging.debug('Image is already cropped.No cropping needed.') # pylint: disable=pointless-statement None else: raise error_util.CameraItsError('Invalid image size metadata') # Separate the image planes. imgs = [ img[::2].reshape(w * h // 2)[::2].reshape(h // 2, w // 2, 1), img[::2].reshape(w * h // 2)[1::2].reshape(h // 2, w // 2, 1), img[1::2].reshape(w * h // 2)[::2].reshape(h // 2, w // 2, 1), img[1::2].reshape(w * h // 2)[1::2].reshape(h // 2, w // 2, 1) ] idxs = get_canonical_cfa_order(props) return [imgs[i] for i in idxs] elif cap['format'] == 'rawStats': assert props is not None white_level = float(props['android.sensor.info.whiteLevel']) # pylint: disable=unused-variable mean_image, var_image = unpack_rawstats_capture(cap) idxs = get_canonical_cfa_order(props) return [mean_image[:, :, i] / white_level for i in idxs] else: raise error_util.CameraItsError('Invalid format %s' % (cap['format'])) def convert_raw_to_rgb_image(r_plane, gr_plane, gb_plane, b_plane, props, cap_res): """Convert a Bayer raw-16 image to an RGB image. Includes some extremely rudimentary demosaicking and color processing operations; the output of this function shouldn't be used for any image quality analysis. Args: r_plane: gr_plane: gb_plane: b_plane: Numpy arrays for each color plane in the Bayer image, with pixels in the [0.0, 1.0] range. props: Camera properties object. cap_res: Capture result (metadata) object. Returns: RGB float-3 image array, with pixel values in [0.0, 1.0] """ # Values required for the RAW to RGB conversion. assert props is not None white_level = float(props['android.sensor.info.whiteLevel']) black_levels = props['android.sensor.blackLevelPattern'] gains = cap_res['android.colorCorrection.gains'] ccm = cap_res['android.colorCorrection.transform'] # Reorder black levels and gains to R,Gr,Gb,B, to match the order # of the planes. black_levels = [get_black_level(i, props, cap_res) for i in range(4)] gains = get_gains_in_canonical_order(props, gains) # Convert CCM from rational to float, as numpy arrays. ccm = numpy.array(capture_request_utils.rational_to_float(ccm)).reshape(3, 3) # Need to scale the image back to the full [0,1] range after subtracting # the black level from each pixel. scale = white_level / (white_level - max(black_levels)) # Three-channel black levels, normalized to [0,1] by white_level. black_levels = numpy.array( [b / white_level for b in [black_levels[i] for i in [0, 1, 3]]]) # Three-channel gains. gains = numpy.array([gains[i] for i in [0, 1, 3]]) h, w = r_plane.shape[:2] img = numpy.dstack([r_plane, (gr_plane + gb_plane) / 2.0, b_plane]) img = (((img.reshape(h, w, 3) - black_levels) * scale) * gains).clip(0.0, 1.0) img = numpy.dot(img.reshape(w * h, 3), ccm.T).reshape(h, w, 3).clip(0.0, 1.0) return img def convert_y8_to_rgb_image(y_plane, w, h): """Convert a Y 8-bit image to an RGB image. Args: y_plane: The packed 8-bit Y plane. w: The width of the image. h: The height of the image. Returns: RGB float-3 image array, with pixel values in [0.0, 1.0]. """ y3 = numpy.dstack([y_plane, y_plane, y_plane]) rgb = numpy.empty([h, w, 3], dtype=numpy.uint8) rgb.reshape(w * h * 3)[:] = y3.reshape(w * h * 3)[:] return rgb.astype(numpy.float32) / 255.0 def write_image(img, fname, apply_gamma=False): """Save a float-3 numpy array image to a file. Supported formats: PNG, JPEG, and others; see PIL docs for more. Image can be 3-channel, which is interpreted as RGB, or can be 1-channel, which is greyscale. Can optionally specify that the image should be gamma-encoded prior to writing it out; this should be done if the image contains linear pixel values, to make the image look "normal". Args: img: Numpy image array data. fname: Path of file to save to; the extension specifies the format. apply_gamma: (Optional) apply gamma to the image prior to writing it. """ if apply_gamma: img = apply_lut_to_image(img, DEFAULT_GAMMA_LUT) (h, w, chans) = img.shape if chans == 3: Image.fromarray((img * 255.0).astype(numpy.uint8), 'RGB').save(fname) elif chans == 1: img3 = (img * 255.0).astype(numpy.uint8).repeat(3).reshape(h, w, 3) Image.fromarray(img3, 'RGB').save(fname) else: raise error_util.CameraItsError('Unsupported image type') def read_image(fname): """Read image function to match write_image() above.""" return Image.open(fname) def apply_lut_to_image(img, lut): """Applies a LUT to every pixel in a float image array. Internally converts to a 16b integer image, since the LUT can work with up to 16b->16b mappings (i.e. values in the range [0,65535]). The lut can also have fewer than 65536 entries, however it must be sized as a power of 2 (and for smaller luts, the scale must match the bitdepth). For a 16b lut of 65536 entries, the operation performed is: lut[r * 65535] / 65535 -> r' lut[g * 65535] / 65535 -> g' lut[b * 65535] / 65535 -> b' For a 10b lut of 1024 entries, the operation becomes: lut[r * 1023] / 1023 -> r' lut[g * 1023] / 1023 -> g' lut[b * 1023] / 1023 -> b' Args: img: Numpy float image array, with pixel values in [0,1]. lut: Numpy table encoding a LUT, mapping 16b integer values. Returns: Float image array after applying LUT to each pixel. """ n = len(lut) if n <= 0 or n > MAX_LUT_SIZE or (n & (n - 1)) != 0: raise error_util.CameraItsError('Invalid arg LUT size: %d' % (n)) m = float(n - 1) return (lut[(img * m).astype(numpy.uint16)] / m).astype(numpy.float32) def get_gains_in_canonical_order(props, gains): """Reorders the gains tuple to the canonical R,Gr,Gb,B order. Args: props: Camera properties object. gains: List of 4 values, in R,G_even,G_odd,B order. Returns: List of gains values, in R,Gr,Gb,B order. """ cfa_pat = props['android.sensor.info.colorFilterArrangement'] if cfa_pat in [0, 1]: # RGGB or GRBG, so G_even is Gr return gains elif cfa_pat in [2, 3]: # GBRG or BGGR, so G_even is Gb return [gains[0], gains[2], gains[1], gains[3]] else: raise error_util.CameraItsError('Not supported') def get_black_level(chan, props, cap_res=None): """Return the black level to use for a given capture. Uses a dynamic value from the capture result if available, else falls back to the static global value in the camera characteristics. Args: chan: The channel index, in canonical order (R, Gr, Gb, B). props: The camera properties object. cap_res: A capture result object. Returns: The black level value for the specified channel. """ if (cap_res is not None and 'android.sensor.dynamicBlackLevel' in cap_res and cap_res['android.sensor.dynamicBlackLevel'] is not None): black_levels = cap_res['android.sensor.dynamicBlackLevel'] else: black_levels = props['android.sensor.blackLevelPattern'] idxs = get_canonical_cfa_order(props) ordered_black_levels = [black_levels[i] for i in idxs] return ordered_black_levels[chan] def get_canonical_cfa_order(props): """Returns a mapping to the standard order R,Gr,Gb,B. Returns a mapping from the Bayer 2x2 top-left grid in the CFA to the standard order R,Gr,Gb,B. Args: props: Camera properties object. Returns: List of 4 integers, corresponding to the positions in the 2x2 top- left Bayer grid of R,Gr,Gb,B, where the 2x2 grid is labeled as 0,1,2,3 in row major order. """ # Note that raw streams aren't croppable, so the cropRegion doesn't need # to be considered when determining the top-left pixel color. cfa_pat = props['android.sensor.info.colorFilterArrangement'] if cfa_pat == 0: # RGGB return [0, 1, 2, 3] elif cfa_pat == 1: # GRBG return [1, 0, 3, 2] elif cfa_pat == 2: # GBRG return [2, 3, 0, 1] elif cfa_pat == 3: # BGGR return [3, 2, 1, 0] else: raise error_util.CameraItsError('Not supported') def unpack_rawstats_capture(cap): """Unpack a rawStats capture to the mean and variance images. Args: cap: A capture object as returned by its_session_utils.do_capture. Returns: Tuple (mean_image var_image) of float-4 images, with non-normalized pixel values computed from the RAW16 images on the device """ assert cap['format'] == 'rawStats' w = cap['width'] h = cap['height'] img = numpy.ndarray(shape=(2 * h * w * 4,), dtype='