1#
2# The Python Imaging Library.
3# $Id$
4#
5# MPO file handling
6#
7# See "Multi-Picture Format" (CIPA DC-007-Translation 2009, Standard of the
8# Camera & Imaging Products Association)
9#
10# The multi-picture object combines multiple JPEG images (with a modified EXIF
11# data format) into a single file. While it can theoretically be used much like
12# a GIF animation, it is commonly used to represent 3D photographs and is (as
13# of this writing) the most commonly used format by 3D cameras.
14#
15# History:
16# 2014-03-13 Feneric Created
17#
18# See the README file for information on usage and redistribution.
19#
20from __future__ import annotations
21
22import itertools
23import os
24import struct
25from typing import IO, Any, cast
26
27from . import (
28 Image,
29 ImageFile,
30 ImageSequence,
31 JpegImagePlugin,
32 TiffImagePlugin,
33)
34from ._binary import o32le
35
36
37def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
38 JpegImagePlugin._save(im, fp, filename)
39
40
41def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
42 append_images = im.encoderinfo.get("append_images", [])
43 if not append_images and not getattr(im, "is_animated", False):
44 _save(im, fp, filename)
45 return
46
47 mpf_offset = 28
48 offsets: list[int] = []
49 for imSequence in itertools.chain([im], append_images):
50 for im_frame in ImageSequence.Iterator(imSequence):
51 if not offsets:
52 # APP2 marker
53 im_frame.encoderinfo["extra"] = (
54 b"\xFF\xE2" + struct.pack(">H", 6 + 82) + b"MPF\0" + b" " * 82
55 )
56 exif = im_frame.encoderinfo.get("exif")
57 if isinstance(exif, Image.Exif):
58 exif = exif.tobytes()
59 im_frame.encoderinfo["exif"] = exif
60 if exif:
61 mpf_offset += 4 + len(exif)
62
63 JpegImagePlugin._save(im_frame, fp, filename)
64 offsets.append(fp.tell())
65 else:
66 im_frame.save(fp, "JPEG")
67 offsets.append(fp.tell() - offsets[-1])
68
69 ifd = TiffImagePlugin.ImageFileDirectory_v2()
70 ifd[0xB000] = b"0100"
71 ifd[0xB001] = len(offsets)
72
73 mpentries = b""
74 data_offset = 0
75 for i, size in enumerate(offsets):
76 if i == 0:
77 mptype = 0x030000 # Baseline MP Primary Image
78 else:
79 mptype = 0x000000 # Undefined
80 mpentries += struct.pack("<LLLHH", mptype, size, data_offset, 0, 0)
81 if i == 0:
82 data_offset -= mpf_offset
83 data_offset += size
84 ifd[0xB002] = mpentries
85
86 fp.seek(mpf_offset)
87 fp.write(b"II\x2A\x00" + o32le(8) + ifd.tobytes(8))
88 fp.seek(0, os.SEEK_END)
89
90
91##
92# Image plugin for MPO images.
93
94
95class MpoImageFile(JpegImagePlugin.JpegImageFile):
96 format = "MPO"
97 format_description = "MPO (CIPA DC-007)"
98 _close_exclusive_fp_after_loading = False
99
100 def _open(self) -> None:
101 self.fp.seek(0) # prep the fp in order to pass the JPEG test
102 JpegImagePlugin.JpegImageFile._open(self)
103 self._after_jpeg_open()
104
105 def _after_jpeg_open(self, mpheader: dict[int, Any] | None = None) -> None:
106 self.mpinfo = mpheader if mpheader is not None else self._getmp()
107 if self.mpinfo is None:
108 msg = "Image appears to be a malformed MPO file"
109 raise ValueError(msg)
110 self.n_frames = self.mpinfo[0xB001]
111 self.__mpoffsets = [
112 mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002]
113 ]
114 self.__mpoffsets[0] = 0
115 # Note that the following assertion will only be invalid if something
116 # gets broken within JpegImagePlugin.
117 assert self.n_frames == len(self.__mpoffsets)
118 del self.info["mpoffset"] # no longer needed
119 self.is_animated = self.n_frames > 1
120 self._fp = self.fp # FIXME: hack
121 self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame
122 self.__frame = 0
123 self.offset = 0
124 # for now we can only handle reading and individual frame extraction
125 self.readonly = 1
126
127 def load_seek(self, pos: int) -> None:
128 self._fp.seek(pos)
129
130 def seek(self, frame: int) -> None:
131 if not self._seek_check(frame):
132 return
133 self.fp = self._fp
134 self.offset = self.__mpoffsets[frame]
135
136 original_exif = self.info.get("exif")
137 if "exif" in self.info:
138 del self.info["exif"]
139
140 self.fp.seek(self.offset + 2) # skip SOI marker
141 if not self.fp.read(2):
142 msg = "No data found for frame"
143 raise ValueError(msg)
144 self.fp.seek(self.offset)
145 JpegImagePlugin.JpegImageFile._open(self)
146 if self.info.get("exif") != original_exif:
147 self._reload_exif()
148
149 self.tile = [
150 ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])
151 ]
152 self.__frame = frame
153
154 def tell(self) -> int:
155 return self.__frame
156
157 @staticmethod
158 def adopt(
159 jpeg_instance: JpegImagePlugin.JpegImageFile,
160 mpheader: dict[int, Any] | None = None,
161 ) -> MpoImageFile:
162 """
163 Transform the instance of JpegImageFile into
164 an instance of MpoImageFile.
165 After the call, the JpegImageFile is extended
166 to be an MpoImageFile.
167
168 This is essentially useful when opening a JPEG
169 file that reveals itself as an MPO, to avoid
170 double call to _open.
171 """
172 jpeg_instance.__class__ = MpoImageFile
173 mpo_instance = cast(MpoImageFile, jpeg_instance)
174 mpo_instance._after_jpeg_open(mpheader)
175 return mpo_instance
176
177
178# ---------------------------------------------------------------------
179# Registry stuff
180
181# Note that since MPO shares a factory with JPEG, we do not need to do a
182# separate registration for it here.
183# Image.register_open(MpoImageFile.format,
184# JpegImagePlugin.jpeg_factory, _accept)
185Image.register_save(MpoImageFile.format, _save)
186Image.register_save_all(MpoImageFile.format, _save_all)
187
188Image.register_extension(MpoImageFile.format, ".mpo")
189
190Image.register_mime(MpoImageFile.format, "image/mpo")