diff --git a/toolkit/image_utils.py b/toolkit/image_utils.py index 68844e2..9173744 100644 --- a/toolkit/image_utils.py +++ b/toolkit/image_utils.py @@ -28,6 +28,69 @@ ICO = types['ICO'] = 'ICO' JPEG = types['JPEG'] = 'JPEG' PNG = types['PNG'] = 'PNG' TIFF = types['TIFF'] = 'TIFF' +WEBP = types['WEBP'] = 'WEBP' + +# EXIF orientations 5-8 are transposed: the displayed image swaps width/height +_EXIF_SWAP_ORIENTATIONS = (5, 6, 7, 8) +# SOF0-15 minus DHT (C4), JPG (C8), DAC (CC) +_JPEG_SOF_MARKERS = frozenset(range(0xC0, 0xD0)) - {0xC4, 0xC8, 0xCC} +# markers with no length field: TEM, RST0-7, SOI +_JPEG_STANDALONE_MARKERS = frozenset(range(0xD0, 0xD8)) | {0x01, 0xD8} + + +def _exif_orientation(exif): + """Orientation tag (0x0112) from a TIFF-format EXIF blob, or None.""" + if exif.startswith(b"Exif\x00\x00"): + exif = exif[6:] + if len(exif) < 8 or exif[:2] not in (b"II", b"MM"): + return None + bo = "<" if exif[:2] == b"II" else ">" + try: + ifd = struct.unpack(bo + "I", exif[4:8])[0] + count = struct.unpack(bo + "H", exif[ifd:ifd + 2])[0] + for i in range(count): + off = ifd + 2 + i * 12 + tag, typ, n = struct.unpack(bo + "HHI", exif[off:off + 8]) + if tag == 0x0112: + if typ != 3 or n != 1: + return None + return struct.unpack(bo + "H", exif[off + 8:off + 10])[0] + except struct.error: + return None + return None + + +def _png_exif_orientation(input): + """Walk PNG chunks after IHDR for eXIf; it must precede IDAT, so stop there.""" + pos = 33 # signature + IHDR chunk + try: + while True: + input.seek(pos) + head = input.read(8) + if len(head) < 8: + return None + length, ctype = struct.unpack(">I4s", head) + if ctype in (b"IDAT", b"IEND"): + return None + if ctype == b"eXIf": + return _exif_orientation(input.read(length)) + pos += 12 + length + except struct.error: + return None + + +def _webp_exif_orientation(input, size, pos): + """Walk RIFF chunks from pos for EXIF; a few seeks, never the bitstream.""" + try: + while pos + 8 <= size: + input.seek(pos) + fourcc, length = struct.unpack("<4sI", input.read(8)) + if fourcc == b"EXIF": + return _exif_orientation(input.read(length)) + pos += 8 + length + (length & 1) + except struct.error: + return None + return None image_fields = ['path', 'type', 'file_size', 'width', 'height'] @@ -101,6 +164,9 @@ def get_image_metadata_from_bytesio(input, size, file_path=None): Return an `Image` object for a given img file content - no external dependencies except the os and struct builtin modules + Width/height honor the EXIF orientation tag (JPEG APP1, PNG eXIf, WebP + EXIF chunk), so they match `PIL.ImageOps.exif_transpose(...).size`. + Args: input (io.IOBase): io object support read & seek size (int): size of buffer in byte @@ -111,7 +177,8 @@ def get_image_metadata_from_bytesio(input, size, file_path=None): """ height = -1 width = -1 - data = input.read(26) + orientation = None + data = input.read(30) msg = " raised while trying to decode as JPEG." if (size >= 10) and data[:6] in (b'GIF87a', b'GIF89a'): @@ -127,40 +194,75 @@ def get_image_metadata_from_bytesio(input, size, file_path=None): w, h = struct.unpack(">LL", data[16:24]) width = int(w) height = int(h) - elif (size >= 16) and data.startswith(b'\211PNG\r\n\032\n'): - # older PNGs - imgtype = PNG - w, h = struct.unpack(">LL", data[8:16]) - width = int(w) - height = int(h) + orientation = _png_exif_orientation(input) elif (size >= 2) and data.startswith(b'\377\330'): # JPEG imgtype = JPEG - input.seek(0) - input.read(2) - b = input.read(1) + input.seek(2) try: - while (b and ord(b) != 0xDA): - while (ord(b) != 0xFF): - b = input.read(1) - while (ord(b) == 0xFF): - b = input.read(1) - if (ord(b) >= 0xC0 and ord(b) <= 0xC3): - input.read(3) + while True: + b = input.read(1) + if not b: + raise UnknownImageFormat("no SOF marker" + msg) + if b != b'\xff': + continue + marker = input.read(1) + while marker == b'\xff': # fill bytes + marker = input.read(1) + if not marker: + raise UnknownImageFormat("no SOF marker" + msg) + m = ord(marker) + if m in _JPEG_STANDALONE_MARKERS: + continue + if m in (0xD9, 0xDA): # EOI / SOS before any SOF + raise UnknownImageFormat("no SOF marker" + msg) + seg_len = struct.unpack(">H", input.read(2))[0] - 2 + if m in _JPEG_SOF_MARKERS: + input.read(1) # sample precision h, w = struct.unpack(">HH", input.read(4)) break + if m == 0xE1 and orientation is None: + # first Exif APP1 wins, same as PIL + orientation = _exif_orientation(input.read(seg_len)) else: - input.read( - int(struct.unpack(">H", input.read(2))[0]) - 2) - b = input.read(1) + input.seek(seg_len, 1) width = int(w) height = int(h) + except UnknownImageFormat: + raise except struct.error: raise UnknownImageFormat("StructError" + msg) except ValueError: raise UnknownImageFormat("ValueError" + msg) except Exception as e: raise UnknownImageFormat(e.__class__.__name__ + msg) + elif (size >= 30) and data[:4] == b'RIFF' and data[8:12] == b'WEBP': + # WebP: RIFF header, then first chunk fourcc at 12, chunk size at 16, payload at 20 + imgtype = WEBP + chunk = data[12:16] + if chunk == b'VP8 ': + # lossy: 3-byte frame tag, 3-byte start code, then 14-bit width/height + if data[23:26] != b'\x9d\x01\x2a': + raise UnknownImageFormat("bad VP8 start code") + w, h = struct.unpack("> 14) & 0x3FFF) + 1 + elif chunk == b'VP8X': + # extended: flags byte, 3 reserved, then 24-bit canvas width-1 / height-1 + width = int.from_bytes(data[24:27], 'little') + 1 + height = int.from_bytes(data[27:30], 'little') + 1 + if data[20] & 0x08: # EXIF flag + chunk_size = struct.unpack("= 26) and data.startswith(b'BM'): # BMP imgtype = 'BMP' @@ -245,7 +347,8 @@ def get_image_metadata_from_bytesio(input, size, file_path=None): if 0 != struct.unpack(" 1: @@ -259,6 +362,9 @@ def get_image_metadata_from_bytesio(input, size, file_path=None): else: raise UnknownImageFormat(FILE_UNKNOWN) + if orientation in _EXIF_SWAP_ORIENTATIONS: + width, height = height, width + return Image(path=file_path, type=imgtype, file_size=size,