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