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
35from ._util import DeferredError
36
37
38def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
39 JpegImagePlugin._save(im, fp, filename)
40
41
42def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
43 append_images = im.encoderinfo.get("append_images", [])
44 if not append_images and not getattr(im, "is_animated", False):
45 _save(im, fp, filename)
46 return
47
48 mpf_offset = 28
49 offsets: list[int] = []
50 for imSequence in itertools.chain([im], append_images):
51 for im_frame in ImageSequence.Iterator(imSequence):
52 if not offsets:
53 # APP2 marker
54 im_frame.encoderinfo["extra"] = (
55 b"\xff\xe2" + struct.pack(">H", 6 + 82) + b"MPF\0" + b" " * 82
56 )
57 exif = im_frame.encoderinfo.get("exif")
58 if isinstance(exif, Image.Exif):
59 exif = exif.tobytes()
60 im_frame.encoderinfo["exif"] = exif
61 if exif:
62 mpf_offset += 4 + len(exif)
63
64 JpegImagePlugin._save(im_frame, fp, filename)
65 offsets.append(fp.tell())
66 else:
67 im_frame.save(fp, "JPEG")
68 offsets.append(fp.tell() - offsets[-1])
69
70 ifd = TiffImagePlugin.ImageFileDirectory_v2()
71 ifd[0xB000] = b"0100"
72 ifd[0xB001] = len(offsets)
73
74 mpentries = b""
75 data_offset = 0
76 for i, size in enumerate(offsets):
77 if i == 0:
78 mptype = 0x030000 # Baseline MP Primary Image
79 else:
80 mptype = 0x000000 # Undefined
81 mpentries += struct.pack("<LLLHH", mptype, size, data_offset, 0, 0)
82 if i == 0:
83 data_offset -= mpf_offset
84 data_offset += size
85 ifd[0xB002] = mpentries
86
87 fp.seek(mpf_offset)
88 fp.write(b"II\x2a\x00" + o32le(8) + ifd.tobytes(8))
89 fp.seek(0, os.SEEK_END)
90
91
92##
93# Image plugin for MPO images.
94
95
96class MpoImageFile(JpegImagePlugin.JpegImageFile):
97 format = "MPO"
98 format_description = "MPO (CIPA DC-007)"
99 _close_exclusive_fp_after_loading = False
100
101 def _open(self) -> None:
102 self.fp.seek(0) # prep the fp in order to pass the JPEG test
103 JpegImagePlugin.JpegImageFile._open(self)
104 self._after_jpeg_open()
105
106 def _after_jpeg_open(self, mpheader: dict[int, Any] | None = None) -> None:
107 self.mpinfo = mpheader if mpheader is not None else self._getmp()
108 if self.mpinfo is None:
109 msg = "Image appears to be a malformed MPO file"
110 raise ValueError(msg)
111 self.n_frames = self.mpinfo[0xB001]
112 self.__mpoffsets = [
113 mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002]
114 ]
115 self.__mpoffsets[0] = 0
116 # Note that the following assertion will only be invalid if something
117 # gets broken within JpegImagePlugin.
118 assert self.n_frames == len(self.__mpoffsets)
119 del self.info["mpoffset"] # no longer needed
120 self.is_animated = self.n_frames > 1
121 self._fp = self.fp # FIXME: hack
122 self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame
123 self.__frame = 0
124 self.offset = 0
125 # for now we can only handle reading and individual frame extraction
126 self.readonly = 1
127
128 def load_seek(self, pos: int) -> None:
129 if isinstance(self._fp, DeferredError):
130 raise self._fp.ex
131 self._fp.seek(pos)
132
133 def seek(self, frame: int) -> None:
134 if not self._seek_check(frame):
135 return
136 if isinstance(self._fp, DeferredError):
137 raise self._fp.ex
138 self.fp = self._fp
139 self.offset = self.__mpoffsets[frame]
140
141 original_exif = self.info.get("exif")
142 if "exif" in self.info:
143 del self.info["exif"]
144
145 self.fp.seek(self.offset + 2) # skip SOI marker
146 if not self.fp.read(2):
147 msg = "No data found for frame"
148 raise ValueError(msg)
149 self.fp.seek(self.offset)
150 JpegImagePlugin.JpegImageFile._open(self)
151 if self.info.get("exif") != original_exif:
152 self._reload_exif()
153
154 self.tile = [
155 ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])
156 ]
157 self.__frame = frame
158
159 def tell(self) -> int:
160 return self.__frame
161
162 @staticmethod
163 def adopt(
164 jpeg_instance: JpegImagePlugin.JpegImageFile,
165 mpheader: dict[int, Any] | None = None,
166 ) -> MpoImageFile:
167 """
168 Transform the instance of JpegImageFile into
169 an instance of MpoImageFile.
170 After the call, the JpegImageFile is extended
171 to be an MpoImageFile.
172
173 This is essentially useful when opening a JPEG
174 file that reveals itself as an MPO, to avoid
175 double call to _open.
176 """
177 jpeg_instance.__class__ = MpoImageFile
178 mpo_instance = cast(MpoImageFile, jpeg_instance)
179 mpo_instance._after_jpeg_open(mpheader)
180 return mpo_instance
181
182
183# ---------------------------------------------------------------------
184# Registry stuff
185
186# Note that since MPO shares a factory with JPEG, we do not need to do a
187# separate registration for it here.
188# Image.register_open(MpoImageFile.format,
189# JpegImagePlugin.jpeg_factory, _accept)
190Image.register_save(MpoImageFile.format, _save)
191Image.register_save_all(MpoImageFile.format, _save_all)
192
193Image.register_extension(MpoImageFile.format, ".mpo")
194
195Image.register_mime(MpoImageFile.format, "image/mpo")