diff --git a/.gitignore b/.gitignore index a0ba1b4c1..95ed4bac5 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,9 @@ docs/_build/ \#*# .#* +#Komodo +*.komodoproject + +#OS +.DS_Store + diff --git a/PIL/Image.py b/PIL/Image.py index a61aaa62b..480410eff 100644 --- a/PIL/Image.py +++ b/PIL/Image.py @@ -555,7 +555,6 @@ class Image: self.readonly = 0 def _dump(self, file=None, format=None): - import os import tempfile suffix = '' if format: diff --git a/PIL/JpegImagePlugin.py b/PIL/JpegImagePlugin.py index a434c5581..9cbab6b61 100644 --- a/PIL/JpegImagePlugin.py +++ b/PIL/JpegImagePlugin.py @@ -36,7 +36,9 @@ __version__ = "0.6" import array import struct -from PIL import Image, ImageFile, _binary +import io +from struct import unpack +from PIL import Image, ImageFile, TiffImagePlugin, _binary from PIL.JpegPresets import presets from PIL._util import isStringType @@ -110,6 +112,11 @@ def APP(self, marker): pass else: self.info["adobe_transform"] = adobe_transform + elif marker == 0xFFE2 and s[:4] == b"MPF\0": + # extract MPO information + self.info["mp"] = s[4:] + # offset is current location minus buffer size plus constant header size + self.info["mpoffset"] = self.fp.tell() - n + 4 def COM(self, marker): @@ -380,18 +387,22 @@ class JpegImageFile(ImageFile.ImageFile): def _getexif(self): return _getexif(self) + def _getmp(self): + return _getmp(self) + + +def _fixup(value): + # Helper function for _getexif() and _getmp() + if len(value) == 1: + return value[0] + return value + def _getexif(self): # Extract EXIF information. This method is highly experimental, # and is likely to be replaced with something better in a future # version. - from PIL import TiffImagePlugin - import io - def fixup(value): - if len(value) == 1: - return value[0] - return value # The EXIF record consists of a TIFF file embedded in a JPEG # application marker (!). try: @@ -405,7 +416,7 @@ def _getexif(self): info = TiffImagePlugin.ImageFileDirectory(head) info.load(file) for key, value in info.items(): - exif[key] = fixup(value) + exif[key] = _fixup(value) # get exif extension try: file.seek(exif[0x8769]) @@ -415,7 +426,7 @@ def _getexif(self): info = TiffImagePlugin.ImageFileDirectory(head) info.load(file) for key, value in info.items(): - exif[key] = fixup(value) + exif[key] = _fixup(value) # get gpsinfo extension try: file.seek(exif[0x8825]) @@ -426,9 +437,77 @@ def _getexif(self): info.load(file) exif[0x8825] = gps = {} for key, value in info.items(): - gps[key] = fixup(value) + gps[key] = _fixup(value) return exif + +def _getmp(self): + # Extract MP information. This method was inspired by the "highly + # experimental" _getexif version that's been in use for years now, + # itself based on the ImageFileDirectory class in the TIFF plug-in. + + # The MP record essentially consists of a TIFF file embedded in a JPEG + # application marker. + try: + data = self.info["mp"] + except KeyError: + return None + file = io.BytesIO(data) + head = file.read(8) + endianness = '>' if head[:4] == b'\x4d\x4d\x00\x2a' else '<' + mp = {} + # process dictionary + info = TiffImagePlugin.ImageFileDirectory(head) + info.load(file) + for key, value in info.items(): + mp[key] = _fixup(value) + # it's an error not to have a number of images + try: + quant = mp[0xB001] + except KeyError: + raise SyntaxError("malformed MP Index (no number of images)") + # get MP entries + try: + mpentries = [] + for entrynum in range(0, quant): + rawmpentry = mp[0xB002][entrynum * 16:(entrynum + 1) * 16] + unpackedentry = unpack('{0}LLLHH'.format(endianness), rawmpentry) + labels = ('Attribute', 'Size', 'DataOffset', 'EntryNo1', 'EntryNo2') + mpentry = dict(zip(labels, unpackedentry)) + mpentryattr = { + 'DependentParentImageFlag': bool(mpentry['Attribute'] & (1<<31)), + 'DependentChildImageFlag': bool(mpentry['Attribute'] & (1<<30)), + 'RepresentativeImageFlag': bool(mpentry['Attribute'] & (1<<29)), + 'Reserved': (mpentry['Attribute'] & (3<<27)) >> 27, + 'ImageDataFormat': (mpentry['Attribute'] & (7<<24)) >> 24, + 'MPType': mpentry['Attribute'] & 0x00FFFFFF + } + if mpentryattr['ImageDataFormat'] == 0: + mpentryattr['ImageDataFormat'] = 'JPEG' + else: + raise SyntaxError("unsupported picture format in MPO") + mptypemap = { + 0x000000: 'Undefined', + 0x010001: 'Large Thumbnail (VGA Equivalent)', + 0x010002: 'Large Thumbnail (Full HD Equivalent)', + 0x020001: 'Multi-Frame Image (Panorama)', + 0x020002: 'Multi-Frame Image: (Disparity)', + 0x020003: 'Multi-Frame Image: (Multi-Angle)', + 0x030000: 'Baseline MP Primary Image' + } + mpentryattr['MPType'] = mptypemap.get(mpentryattr['MPType'], + 'Unknown') + mpentry['Attribute'] = mpentryattr + mpentries.append(mpentry) + mp[0xB002] = mpentries + except KeyError: + raise SyntaxError("malformed MP Index (bad MP Entry)") + # Next we should try and parse the individual image unique ID list; + # we don't because I've never seen this actually used in a real MPO + # file and so can't test it. + return mp + + # -------------------------------------------------------------------- # stuff to save JPEG files @@ -611,10 +690,27 @@ def _save_cjpeg(im, fp, filename): except: pass + +## +# Factory for making JPEG and MPO instances +def jpeg_factory(fp=None, filename=None): + im = JpegImageFile(fp, filename) + mpheader = im._getmp() + try: + if mpheader[45057] > 1: + # It's actually an MPO + from .MpoImagePlugin import MpoImageFile + im = MpoImageFile(fp, filename) + except (TypeError, IndexError): + # It is really a JPEG + pass + return im + + # -------------------------------------------------------------------q- # Registry stuff -Image.register_open("JPEG", JpegImageFile, _accept) +Image.register_open("JPEG", jpeg_factory, _accept) Image.register_save("JPEG", _save) Image.register_extension("JPEG", ".jfif") diff --git a/PIL/MpoImagePlugin.py b/PIL/MpoImagePlugin.py new file mode 100644 index 000000000..520e683d4 --- /dev/null +++ b/PIL/MpoImagePlugin.py @@ -0,0 +1,86 @@ +# +# The Python Imaging Library. +# $Id$ +# +# MPO file handling +# +# See "Multi-Picture Format" (CIPA DC-007-Translation 2009, Standard of the +# Camera & Imaging Products Association) +# +# The multi-picture object combines multiple JPEG images (with a modified EXIF +# data format) into a single file. While it can theoretically be used much like +# a GIF animation, it is commonly used to represent 3D photographs and is (as +# of this writing) the most commonly used format by 3D cameras. +# +# History: +# 2014-03-13 Feneric Created +# +# See the README file for information on usage and redistribution. +# + +__version__ = "0.1" + +from PIL import Image, JpegImagePlugin + +def _accept(prefix): + return JpegImagePlugin._accept(prefix) + +def _save(im, fp, filename): + return JpegImagePlugin._save(im, fp, filename) + +## +# Image plugin for MPO images. + +class MpoImageFile(JpegImagePlugin.JpegImageFile): + + format = "MPO" + format_description = "MPO (CIPA DC-007)" + + def _open(self): + self.fp.seek(0) # prep the fp in order to pass the JPEG test + JpegImagePlugin.JpegImageFile._open(self) + self.mpinfo = self._getmp() + self.__framecount = self.mpinfo[0xB001] + self.__mpoffsets = [mpent['DataOffset'] + self.info['mpoffset'] \ + for mpent in self.mpinfo[0xB002]] + self.__mpoffsets[0] = 0 + # Note that the following assertion will only be invalid if something + # gets broken within JpegImagePlugin. + assert self.__framecount == len(self.__mpoffsets) + del self.info['mpoffset'] # no longer needed + self.__fp = self.fp # FIXME: hack + self.__fp.seek(self.__mpoffsets[0]) # get ready to read first frame + self.__frame = 0 + self.offset = 0 + # for now we can only handle reading and individual frame extraction + self.readonly = 1 + + def load_seek(self, pos): + self.__fp.seek(pos) + + def seek(self, frame): + if frame < 0 or frame >= self.__framecount: + raise EOFError("no more images in MPO file") + else: + self.fp = self.__fp + self.offset = self.__mpoffsets[frame] + self.tile = [ + ("jpeg", (0, 0) + self.size, self.offset, (self.mode, "")) + ] + self.__frame = frame + + def tell(self): + return self.__frame + + +# -------------------------------------------------------------------q- +# Registry stuff + +# Note that since MPO shares a factory with JPEG, we do not need to do a +# separate registration for it here. +#Image.register_open("MPO", JpegImagePlugin.jpeg_factory, _accept) +Image.register_save("MPO", _save) + +Image.register_extension("MPO", ".mpo") + +Image.register_mime("MPO", "image/mpo") diff --git a/PIL/TiffTags.py b/PIL/TiffTags.py index 92a4b5afc..966779ce9 100644 --- a/PIL/TiffTags.py +++ b/PIL/TiffTags.py @@ -147,6 +147,100 @@ TAGS = { # ICC Profile 34675: "ICCProfile", + # Additional Exif Info + 33434: "ExposureTime", + 33437: "FNumber", + 34850: "ExposureProgram", + 34852: "SpectralSensitivity", + 34853: "GPSInfoIFD", + 34855: "ISOSpeedRatings", + 34856: "OECF", + 34864: "SensitivityType", + 34865: "StandardOutputSensitivity", + 34866: "RecommendedExposureIndex", + 34867: "ISOSpeed", + 34868: "ISOSpeedLatitudeyyy", + 34869: "ISOSpeedLatitudezzz", + 36864: "ExifVersion", + 36867: "DateTimeOriginal", + 36868: "DateTImeDigitized", + 37121: "ComponentsConfiguration", + 37122: "CompressedBitsPerPixel", + 37377: "ShutterSpeedValue", + 37378: "ApertureValue", + 37379: "BrightnessValue", + 37380: "ExposureBiasValue", + 37381: "MaxApertureValue", + 37382: "SubjectDistance", + 37383: "MeteringMode", + 37384: "LightSource", + 37385: "Flash", + 37386: "FocalLength", + 37396: "SubjectArea", + 37500: "MakerNote", + 37510: "UserComment", + 37520: "SubSec", + 37521: "SubSecTimeOriginal", + 37522: "SubsecTimeDigitized", + 40960: "FlashPixVersion", + 40961: "ColorSpace", + 40962: "PixelXDimension", + 40963: "PixelYDimension", + 40964: "RelatedSoundFile", + 40965: "InteroperabilityIFD", + 41483: "FlashEnergy", + 41484: "SpatialFrequencyResponse", + 41486: "FocalPlaneXResolution", + 41487: "FocalPlaneYResolution", + 41488: "FocalPlaneResolutionUnit", + 41492: "SubjectLocation", + 41493: "ExposureIndex", + 41495: "SensingMethod", + 41728: "FileSource", + 41729: "SceneType", + 41730: "CFAPattern", + 41985: "CustomRendered", + 41986: "ExposureMode", + 41987: "WhiteBalance", + 41988: "DigitalZoomRatio", + 41989: "FocalLengthIn35mmFilm", + 41990: "SceneCaptureType", + 41991: "GainControl", + 41992: "Contrast", + 41993: "Saturation", + 41994: "Sharpness", + 41995: "DeviceSettingDescription", + 41996: "SubjectDistanceRange", + 42016: "ImageUniqueID", + 42032: "CameraOwnerName", + 42033: "BodySerialNumber", + 42034: "LensSpecification", + 42035: "LensMake", + 42036: "LensModel", + 42037: "LensSerialNumber", + 42240: "Gamma", + + # MP Info + 45056: "MPFVersion", + 45057: "NumberOfImages", + 45058: "MPEntry", + 45059: "ImageUIDList", + 45060: "TotalFrames", + 45313: "MPIndividualNum", + 45569: "PanOrientation", + 45570: "PanOverlap_H", + 45571: "PanOverlap_V", + 45572: "BaseViewpointNum", + 45573: "ConvergenceAngle", + 45574: "BaselineLength", + 45575: "VerticalDivergence", + 45576: "AxisDistance_X", + 45577: "AxisDistance_Y", + 45578: "AxisDistance_Z", + 45579: "YawAngle", + 45580: "PitchAngle", + 45581: "RollAngle", + # Adobe DNG 50706: "DNGVersion", 50707: "DNGBackwardVersion", diff --git a/PIL/__init__.py b/PIL/__init__.py index d446aa19b..56edaf247 100644 --- a/PIL/__init__.py +++ b/PIL/__init__.py @@ -36,6 +36,7 @@ _plugins = ['BmpImagePlugin', 'McIdasImagePlugin', 'MicImagePlugin', 'MpegImagePlugin', + 'MpoImagePlugin', 'MspImagePlugin', 'PalmImagePlugin', 'PcdImagePlugin', diff --git a/Tests/images/frozenpond.mpo b/Tests/images/frozenpond.mpo new file mode 100644 index 000000000..2dfe44ac1 Binary files /dev/null and b/Tests/images/frozenpond.mpo differ diff --git a/Tests/images/sugarshack.mpo b/Tests/images/sugarshack.mpo new file mode 100644 index 000000000..85346fcc9 Binary files /dev/null and b/Tests/images/sugarshack.mpo differ diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 69c07d2dc..3bf757332 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -216,6 +216,10 @@ class TestFileJpeg(PillowTestCase): info = im._getexif() self.assertEqual(info[305], 'Adobe Photoshop CS Macintosh') + def test_mp(self): + im = Image.open("Tests/images/pil_sample_rgb.jpg") + self.assertIsNone(im._getmp()) + def test_quality_keep(self): im = Image.open("Tests/images/lena.jpg") f = self.tempfile('temp.jpg') diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py new file mode 100644 index 000000000..20589539b --- /dev/null +++ b/Tests/test_file_mpo.py @@ -0,0 +1,114 @@ +from helper import unittest, PillowTestCase +from io import BytesIO +from PIL import Image + + +test_files = ["Tests/images/sugarshack.mpo", "Tests/images/frozenpond.mpo"] + + +class TestFileMpo(PillowTestCase): + + def setUp(self): + codecs = dir(Image.core) + if "jpeg_encoder" not in codecs or "jpeg_decoder" not in codecs: + self.skipTest("jpeg support not available") + + def roundtrip(self, im, **options): + out = BytesIO() + im.save(out, "MPO", **options) + bytes = out.tell() + out.seek(0) + im = Image.open(out) + im.bytes = bytes # for testing only + return im + + def test_sanity(self): + for test_file in test_files: + im = Image.open(test_file) + im.load() + self.assertEqual(im.mode, "RGB") + self.assertEqual(im.size, (640, 480)) + self.assertEqual(im.format, "MPO") + + def test_app(self): + for test_file in test_files: + # Test APP/COM reader (@PIL135) + im = Image.open(test_file) + self.assertEqual(im.applist[0][0], 'APP1') + self.assertEqual(im.applist[1][0], 'APP2') + self.assertEqual(im.applist[1][1][:16], + b'MPF\x00MM\x00*\x00\x00\x00\x08\x00\x03\xb0\x00') + self.assertEqual(len(im.applist), 2) + + def test_exif(self): + for test_file in test_files: + im = Image.open(test_file) + info = im._getexif() + self.assertEqual(info[272], 'Nintendo 3DS') + self.assertEqual(info[296], 2) + self.assertEqual(info[34665], 188) + + def test_mp(self): + for test_file in test_files: + im = Image.open(test_file) + mpinfo = im._getmp() + self.assertEqual(mpinfo[45056], b'0100') + self.assertEqual(mpinfo[45057], 2) + + def test_mp_attribute(self): + for test_file in test_files: + im = Image.open(test_file) + mpinfo = im._getmp() + frameNumber = 0 + for mpentry in mpinfo[45058]: + mpattr = mpentry['Attribute'] + if frameNumber: + self.assertFalse(mpattr['RepresentativeImageFlag']) + else: + self.assertTrue(mpattr['RepresentativeImageFlag']) + self.assertFalse(mpattr['DependentParentImageFlag']) + self.assertFalse(mpattr['DependentChildImageFlag']) + self.assertEqual(mpattr['ImageDataFormat'], 'JPEG') + self.assertEqual(mpattr['MPType'], + 'Multi-Frame Image: (Disparity)') + self.assertEqual(mpattr['Reserved'], 0) + frameNumber += 1 + + def test_seek(self): + for test_file in test_files: + im = Image.open(test_file) + self.assertEqual(im.tell(), 0) + # prior to first image raises an error, both blatant and borderline + self.assertRaises(EOFError, im.seek, -1) + self.assertRaises(EOFError, im.seek, -523) + # after the final image raises an error, both blatant and borderline + self.assertRaises(EOFError, im.seek, 2) + self.assertRaises(EOFError, im.seek, 523) + # bad calls shouldn't change the frame + self.assertEqual(im.tell(), 0) + # this one will work + im.seek(1) + self.assertEqual(im.tell(), 1) + # and this one, too + im.seek(0) + self.assertEqual(im.tell(), 0) + + def test_image_grab(self): + for test_file in test_files: + im = Image.open(test_file) + self.assertEqual(im.tell(), 0) + im0 = im.tobytes() + im.seek(1) + self.assertEqual(im.tell(), 1) + im1 = im.tobytes() + im.seek(0) + self.assertEqual(im.tell(), 0) + im02 = im.tobytes() + self.assertEqual(im0, im02) + self.assertNotEqual(im0, im1) + + +if __name__ == '__main__': + unittest.main() + +# End of file diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 03e55f35a..8c9bb36ec 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -588,6 +588,20 @@ PIL identifies and reads Microsoft Image Composer (MIC) files. When opened, the first sprite in the file is loaded. You can use :py:meth:`~file.seek` and :py:meth:`~file.tell` to read other sprites from the file. +MPO +^^^ + +Pillow identifies and reads Multi Picture Object (MPO) files, loading the primary +image when first opened. The :py:meth:`~file.seek` and :py:meth:`~file.tell` +methods may be used to read other pictures from the file. The pictures are +zero-indexed and random access is supported. + +MIC (read only) + +Pillow identifies and reads Microsoft Image Composer (MIC) files. When opened, the +first sprite in the file is loaded. You can use :py:meth:`~file.seek` and +:py:meth:`~file.tell` to read other sprites from the file. + PCD ^^^