Merge pull request #3761 from radarhere/imageops

Improve exif_transpose
This commit is contained in:
Hugo 2019-04-01 13:33:55 +03:00 committed by GitHub
commit fb79e15f99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 246 additions and 233 deletions

View File

@ -1,7 +1,7 @@
from .helper import PillowTestCase, hopper from .helper import PillowTestCase, hopper
from PIL import ImageOps
from PIL import Image from PIL import Image
from PIL import ImageOps
try: try:
from PIL import _webp from PIL import _webp
@ -239,9 +239,23 @@ class TestImageOps(PillowTestCase):
for i in range(2, 9): for i in range(2, 9):
im = Image.open("Tests/images/hopper_orientation_"+str(i)+ext) im = Image.open("Tests/images/hopper_orientation_"+str(i)+ext)
orientations.append(im) orientations.append(im)
for im in orientations: for i, orientation_im in enumerate(orientations):
for im in [
orientation_im, # ImageFile
orientation_im.copy() # Image
]:
if i == 0:
self.assertNotIn("exif", im.info)
else:
original_exif = im.info["exif"]
transposed_im = ImageOps.exif_transpose(im) transposed_im = ImageOps.exif_transpose(im)
self.assert_image_similar(base_im, transposed_im, 17) self.assert_image_similar(base_im, transposed_im, 17)
if i == 0:
self.assertNotIn("exif", im.info)
else:
self.assertNotEqual(transposed_im.info["exif"], original_exif)
self.assertNotIn(0x0112, transposed_im.getexif())
# Repeat the operation, to test that it does not keep transposing # Repeat the operation, to test that it does not keep transposing
transposed_im2 = ImageOps.exif_transpose(transposed_im) transposed_im2 = ImageOps.exif_transpose(transposed_im)

View File

@ -464,8 +464,9 @@ Pillow identifies, reads, and writes PNG files containing ``1``, ``L``, ``LA``,
v1.1.7. v1.1.7.
As of Pillow 6.0, EXIF data can be read from PNG images. However, unlike other As of Pillow 6.0, EXIF data can be read from PNG images. However, unlike other
image formats, EXIF data is not guaranteed to have been read until image formats, EXIF data is not guaranteed to be present in
:py:meth:`~PIL.Image.Image.load` has been called. :py:attr:`~PIL.Image.Image.info` until :py:meth:`~PIL.Image.Image.load` has been
called.
The :py:meth:`~PIL.Image.Image.open` method sets the following The :py:meth:`~PIL.Image.Image.open` method sets the following
:py:attr:`~PIL.Image.Image.info` properties, when appropriate: :py:attr:`~PIL.Image.Image.info` properties, when appropriate:

View File

@ -165,12 +165,20 @@ language-specific glyphs and ligatures from the font:
* ``ImageFont.ImageFont.getsize_multiline()`` * ``ImageFont.ImageFont.getsize_multiline()``
* ``ImageFont.ImageFont.getsize()`` * ``ImageFont.ImageFont.getsize()``
Added EXIF class
^^^^^^^^^^^^^^^^
:py:meth:`~PIL.Image.Image.getexif` has been added, and returning an
:py:class:`~PIL.Image.Exif` instance. Values can be retrieved and set like a
dictionary. When saving JPEG, PNG or WEBP, the instance can be passed as an
``exif`` argument to include any changes in the output image.
PNG EXIF data PNG EXIF data
^^^^^^^^^^^^^ ^^^^^^^^^^^^^
EXIF data can now be read from and saved to PNG images. However, unlike other image EXIF data can now be read from and saved to PNG images. However, unlike other image
formats, EXIF data is not guaranteed to have been read until formats, EXIF data is not guaranteed to be present in :py:attr:`~PIL.Image.Image.info`
:py:meth:`~PIL.Image.Image.load` has been called. until :py:meth:`~PIL.Image.Image.load` has been called.
Other Changes Other Changes
============= =============

View File

@ -40,8 +40,8 @@ except ImportError:
import __builtin__ import __builtin__
builtins = __builtin__ builtins = __builtin__
from . import ImageMode from . import ImageMode, TiffTags
from ._binary import i8 from ._binary import i8, i32le
from ._util import isPath, isStringType, deferred_error from ._util import isPath, isStringType, deferred_error
import os import os
@ -54,10 +54,10 @@ import atexit
import numbers import numbers
try: try:
# Python 3 # Python 3
from collections.abc import Callable from collections.abc import Callable, MutableMapping
except ImportError: except ImportError:
# Python 2.7 # Python 2.7
from collections import Callable from collections import Callable, MutableMapping
# Silence warning # Silence warning
@ -1297,6 +1297,12 @@ class Image(object):
return tuple(extrema) return tuple(extrema)
return self.im.getextrema() return self.im.getextrema()
def getexif(self):
exif = Exif()
if "exif" in self.info:
exif.load(self.info["exif"])
return exif
def getim(self): def getim(self):
""" """
Returns a capsule that points to the internal image memory. Returns a capsule that points to the internal image memory.
@ -3005,3 +3011,182 @@ def _apply_env_variables(env=None):
_apply_env_variables() _apply_env_variables()
atexit.register(core.clear_cache) atexit.register(core.clear_cache)
class Exif(MutableMapping):
endian = "<"
def __init__(self):
self._data = {}
self._ifds = {}
def _fixup_dict(self, src_dict):
# Helper function for _getexif()
# returns a dict with any single item tuples/lists as individual values
def _fixup(value):
try:
if len(value) == 1 and not isinstance(value, dict):
return value[0]
except Exception:
pass
return value
return {k: _fixup(v) for k, v in src_dict.items()}
def _get_ifd_dict(self, tag):
try:
# an offset pointer to the location of the nested embedded IFD.
# It should be a long, but may be corrupted.
self.fp.seek(self._data[tag])
except (KeyError, TypeError):
pass
else:
from . import TiffImagePlugin
info = TiffImagePlugin.ImageFileDirectory_v1(self.head)
info.load(self.fp)
return self._fixup_dict(info)
def load(self, data):
# Extract EXIF information. This is highly experimental,
# and is likely to be replaced with something better in a future
# version.
# The EXIF record consists of a TIFF file embedded in a JPEG
# application marker (!).
self.fp = io.BytesIO(data[6:])
self.head = self.fp.read(8)
# process dictionary
from . import TiffImagePlugin
info = TiffImagePlugin.ImageFileDirectory_v1(self.head)
self.endian = info._endian
self.fp.seek(info.next)
info.load(self.fp)
self._data = dict(self._fixup_dict(info))
# get EXIF extension
ifd = self._get_ifd_dict(0x8769)
if ifd:
self._data.update(ifd)
self._ifds[0x8769] = ifd
# get gpsinfo extension
ifd = self._get_ifd_dict(0x8825)
if ifd:
self._data[0x8825] = ifd
self._ifds[0x8825] = ifd
def tobytes(self, offset=0):
from . import TiffImagePlugin
if self.endian == "<":
head = b"II\x2A\x00\x08\x00\x00\x00"
else:
head = b"MM\x00\x2A\x00\x00\x00\x08"
ifd = TiffImagePlugin.ImageFileDirectory_v2(ifh=head)
for tag, value in self._data.items():
ifd[tag] = value
return b"Exif\x00\x00"+head+ifd.tobytes(offset)
def get_ifd(self, tag):
if tag not in self._ifds and tag in self._data:
if tag == 0xa005: # interop
self._ifds[tag] = self._get_ifd_dict(tag)
elif tag == 0x927c: # makernote
from . import TiffImagePlugin
if self._data[0x927c][:8] == b"FUJIFILM":
exif_data = self._data[0x927c]
ifd_offset = i32le(exif_data[8:12])
ifd_data = exif_data[ifd_offset:]
makernote = {}
for i in range(0, struct.unpack("<H", ifd_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack(
"<HHL4s", ifd_data[i*12 + 2:(i+1)*12 + 2])
try:
unit_size, handler =\
TiffImagePlugin.ImageFileDirectory_v2._load_dispatch[
typ
]
except KeyError:
continue
size = count * unit_size
if size > 4:
offset, = struct.unpack("<L", data)
data = ifd_data[offset-12:offset+size-12]
else:
data = data[:size]
if len(data) != size:
warnings.warn("Possibly corrupt EXIF MakerNote data. "
"Expecting to read %d bytes but only got %d."
" Skipping tag %s"
% (size, len(data), ifd_tag))
continue
if not data:
continue
makernote[ifd_tag] = handler(
TiffImagePlugin.ImageFileDirectory_v2(), data, False)
self._ifds[0x927c] = dict(self._fixup_dict(makernote))
elif self._data.get(0x010f) == "Nintendo":
ifd_data = self._data[0x927c]
makernote = {}
for i in range(0, struct.unpack(">H", ifd_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack(
">HHL4s", ifd_data[i*12 + 2:(i+1)*12 + 2])
if ifd_tag == 0x1101:
# CameraInfo
offset, = struct.unpack(">L", data)
self.fp.seek(offset)
camerainfo = {'ModelID': self.fp.read(4)}
self.fp.read(4)
# Seconds since 2000
camerainfo['TimeStamp'] = i32le(self.fp.read(12))
self.fp.read(4)
camerainfo['InternalSerialNumber'] = self.fp.read(4)
self.fp.read(12)
parallax = self.fp.read(4)
handler =\
TiffImagePlugin.ImageFileDirectory_v2._load_dispatch[
TiffTags.FLOAT
][1]
camerainfo['Parallax'] = handler(
TiffImagePlugin.ImageFileDirectory_v2(),
parallax, False)
self.fp.read(4)
camerainfo['Category'] = self.fp.read(2)
makernote = {0x1101: dict(self._fixup_dict(camerainfo))}
self._ifds[0x927c] = makernote
return self._ifds.get(tag, {})
def __str__(self):
return str(self._data)
def __len__(self):
return len(self._data)
def __getitem__(self, tag):
return self._data[tag]
def __contains__(self, tag):
return tag in self._data
if not py3:
def has_key(self, tag):
return tag in self
def __setitem__(self, tag, value):
self._data[tag] = value
def __delitem__(self, tag):
del self._data[tag]
def __iter__(self):
return iter(set(self._data))

View File

@ -27,20 +27,11 @@
# See the README file for information on usage and redistribution. # See the README file for information on usage and redistribution.
# #
from . import Image, TiffTags from . import Image
from ._binary import i32le from ._util import isPath
from ._util import isPath, py3
import io import io
import sys import sys
import struct import struct
import warnings
try:
# Python 3
from collections.abc import MutableMapping
except ImportError:
# Python 2.7
from collections import MutableMapping
MAXBLOCK = 65536 MAXBLOCK = 65536
@ -302,12 +293,6 @@ class ImageFile(Image.Image):
return self.tell() != frame return self.tell() != frame
def getexif(self):
exif = Exif()
if "exif" in self.info:
exif.load(self.info["exif"])
return exif
class StubImageFile(ImageFile): class StubImageFile(ImageFile):
""" """
@ -687,182 +672,3 @@ class PyDecoder(object):
raise ValueError("not enough image data") raise ValueError("not enough image data")
if s[1] != 0: if s[1] != 0:
raise ValueError("cannot decode image data") raise ValueError("cannot decode image data")
class Exif(MutableMapping):
endian = "<"
def __init__(self):
self._data = {}
self._ifds = {}
def _fixup_dict(self, src_dict):
# Helper function for _getexif()
# returns a dict with any single item tuples/lists as individual values
def _fixup(value):
try:
if len(value) == 1 and not isinstance(value, dict):
return value[0]
except Exception:
pass
return value
return {k: _fixup(v) for k, v in src_dict.items()}
def _get_ifd_dict(self, tag):
try:
# an offset pointer to the location of the nested embedded IFD.
# It should be a long, but may be corrupted.
self.fp.seek(self._data[tag])
except (KeyError, TypeError):
pass
else:
from . import TiffImagePlugin
info = TiffImagePlugin.ImageFileDirectory_v1(self.head)
info.load(self.fp)
return self._fixup_dict(info)
def load(self, data):
# Extract EXIF information. This is highly experimental,
# and is likely to be replaced with something better in a future
# version.
# The EXIF record consists of a TIFF file embedded in a JPEG
# application marker (!).
self.fp = io.BytesIO(data[6:])
self.head = self.fp.read(8)
# process dictionary
from . import TiffImagePlugin
info = TiffImagePlugin.ImageFileDirectory_v1(self.head)
self.endian = info._endian
self.fp.seek(info.next)
info.load(self.fp)
self._data = dict(self._fixup_dict(info))
# get EXIF extension
ifd = self._get_ifd_dict(0x8769)
if ifd:
self._data.update(ifd)
self._ifds[0x8769] = ifd
# get gpsinfo extension
ifd = self._get_ifd_dict(0x8825)
if ifd:
self._data[0x8825] = ifd
self._ifds[0x8825] = ifd
def tobytes(self, offset=0):
from . import TiffImagePlugin
if self.endian == "<":
head = b"II\x2A\x00\x08\x00\x00\x00"
else:
head = b"MM\x00\x2A\x00\x00\x00\x08"
ifd = TiffImagePlugin.ImageFileDirectory_v2(ifh=head)
for tag, value in self._data.items():
ifd[tag] = value
return b"Exif\x00\x00"+head+ifd.tobytes(offset)
def get_ifd(self, tag):
if tag not in self._ifds and tag in self._data:
if tag == 0xa005: # interop
self._ifds[tag] = self._get_ifd_dict(tag)
elif tag == 0x927c: # makernote
from . import TiffImagePlugin
if self._data[0x927c][:8] == b"FUJIFILM":
exif_data = self._data[0x927c]
ifd_offset = i32le(exif_data[8:12])
ifd_data = exif_data[ifd_offset:]
makernote = {}
for i in range(0, struct.unpack("<H", ifd_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack(
"<HHL4s", ifd_data[i*12 + 2:(i+1)*12 + 2])
try:
unit_size, handler =\
TiffImagePlugin.ImageFileDirectory_v2._load_dispatch[
typ
]
except KeyError:
continue
size = count * unit_size
if size > 4:
offset, = struct.unpack("<L", data)
data = ifd_data[offset-12:offset+size-12]
else:
data = data[:size]
if len(data) != size:
warnings.warn("Possibly corrupt EXIF MakerNote data. "
"Expecting to read %d bytes but only got %d."
" Skipping tag %s"
% (size, len(data), ifd_tag))
continue
if not data:
continue
makernote[ifd_tag] = handler(
TiffImagePlugin.ImageFileDirectory_v2(), data, False)
self._ifds[0x927c] = dict(self._fixup_dict(makernote))
elif self._data.get(0x010f) == "Nintendo":
ifd_data = self._data[0x927c]
makernote = {}
for i in range(0, struct.unpack(">H", ifd_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack(
">HHL4s", ifd_data[i*12 + 2:(i+1)*12 + 2])
if ifd_tag == 0x1101:
# CameraInfo
offset, = struct.unpack(">L", data)
self.fp.seek(offset)
camerainfo = {'ModelID': self.fp.read(4)}
self.fp.read(4)
# Seconds since 2000
camerainfo['TimeStamp'] = i32le(self.fp.read(12))
self.fp.read(4)
camerainfo['InternalSerialNumber'] = self.fp.read(4)
self.fp.read(12)
parallax = self.fp.read(4)
handler =\
TiffImagePlugin.ImageFileDirectory_v2._load_dispatch[
TiffTags.FLOAT
][1]
camerainfo['Parallax'] = handler(
TiffImagePlugin.ImageFileDirectory_v2(),
parallax, False)
self.fp.read(4)
camerainfo['Category'] = self.fp.read(2)
makernote = {0x1101: dict(self._fixup_dict(camerainfo))}
self._ifds[0x927c] = makernote
return self._ifds.get(tag, {})
def __str__(self):
return str(self._data)
def __len__(self):
return len(self._data)
def __getitem__(self, tag):
return self._data[tag]
def __contains__(self, tag):
return tag in self._data
if not py3:
def has_key(self, tag):
return tag in self
def __setitem__(self, tag, value):
self._data[tag] = value
def __delitem__(self, tag):
del self._data[tag]
def __iter__(self):
return iter(set(self._data))

View File

@ -532,9 +532,7 @@ def exif_transpose(image):
:param image: The image to transpose. :param image: The image to transpose.
:return: An image. :return: An image.
""" """
if not hasattr(image, '_exif_transposed') and hasattr(image, '_getexif'): exif = image.getexif()
exif = image._getexif()
if exif:
orientation = exif.get(0x0112) orientation = exif.get(0x0112)
method = { method = {
2: Image.FLIP_LEFT_RIGHT, 2: Image.FLIP_LEFT_RIGHT,
@ -547,6 +545,7 @@ def exif_transpose(image):
}.get(orientation) }.get(orientation)
if method is not None: if method is not None:
transposed_image = image.transpose(method) transposed_image = image.transpose(method)
transposed_image._exif_transposed = True del exif[0x0112]
transposed_image.info["exif"] = exif.tobytes()
return transposed_image return transposed_image
return image.copy() return image.copy()

View File

@ -472,7 +472,7 @@ class JpegImageFile(ImageFile.ImageFile):
def _fixup_dict(src_dict): def _fixup_dict(src_dict):
# Helper function for _getexif() # Helper function for _getexif()
# returns a dict with any single item tuples/lists as individual values # returns a dict with any single item tuples/lists as individual values
exif = ImageFile.Exif() exif = Image.Exif()
return exif._fixup_dict(src_dict) return exif._fixup_dict(src_dict)
@ -725,7 +725,7 @@ def _save(im, fp, filename):
optimize = info.get("optimize", False) optimize = info.get("optimize", False)
exif = info.get("exif", b"") exif = info.get("exif", b"")
if isinstance(exif, ImageFile.Exif): if isinstance(exif, Image.Exif):
exif = exif.tobytes() exif = exif.tobytes()
# get keyword arguments # get keyword arguments

View File

@ -886,7 +886,7 @@ def _save(im, fp, filename, chunk=putchunk):
exif = im.encoderinfo.get("exif", im.info.get("exif")) exif = im.encoderinfo.get("exif", im.info.get("exif"))
if exif: if exif:
if isinstance(exif, ImageFile.Exif): if isinstance(exif, Image.Exif):
exif = exif.tobytes(8) exif = exif.tobytes(8)
if exif.startswith(b"Exif\x00\x00"): if exif.startswith(b"Exif\x00\x00"):
exif = exif[6:] exif = exif[6:]

View File

@ -217,7 +217,7 @@ def _save_all(im, fp, filename):
method = im.encoderinfo.get("method", 0) method = im.encoderinfo.get("method", 0)
icc_profile = im.encoderinfo.get("icc_profile", "") icc_profile = im.encoderinfo.get("icc_profile", "")
exif = im.encoderinfo.get("exif", "") exif = im.encoderinfo.get("exif", "")
if isinstance(exif, ImageFile.Exif): if isinstance(exif, Image.Exif):
exif = exif.tobytes() exif = exif.tobytes()
xmp = im.encoderinfo.get("xmp", "") xmp = im.encoderinfo.get("xmp", "")
if allow_mixed: if allow_mixed:
@ -318,7 +318,7 @@ def _save(im, fp, filename):
quality = im.encoderinfo.get("quality", 80) quality = im.encoderinfo.get("quality", 80)
icc_profile = im.encoderinfo.get("icc_profile", "") icc_profile = im.encoderinfo.get("icc_profile", "")
exif = im.encoderinfo.get("exif", "") exif = im.encoderinfo.get("exif", "")
if isinstance(exif, ImageFile.Exif): if isinstance(exif, Image.Exif):
exif = exif.tobytes() exif = exif.tobytes()
xmp = im.encoderinfo.get("xmp", "") xmp = im.encoderinfo.get("xmp", "")