Merge pull request #822 from Feneric/master

MPO Read Support
This commit is contained in:
wiredfool 2014-07-28 21:25:10 -07:00
commit ce3814189e
11 changed files with 426 additions and 12 deletions

6
.gitignore vendored
View File

@ -60,3 +60,9 @@ docs/_build/
\#*#
.#*
#Komodo
*.komodoproject
#OS
.DS_Store

View File

@ -555,7 +555,6 @@ class Image:
self.readonly = 0
def _dump(self, file=None, format=None):
import os
import tempfile
suffix = ''
if format:

View File

@ -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")

86
PIL/MpoImagePlugin.py Normal file
View File

@ -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")

View File

@ -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",

View File

@ -36,6 +36,7 @@ _plugins = ['BmpImagePlugin',
'McIdasImagePlugin',
'MicImagePlugin',
'MpegImagePlugin',
'MpoImagePlugin',
'MspImagePlugin',
'PalmImagePlugin',
'PcdImagePlugin',

BIN
Tests/images/frozenpond.mpo Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

BIN
Tests/images/sugarshack.mpo Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -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')

114
Tests/test_file_mpo.py Normal file
View File

@ -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

View File

@ -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
^^^