Coverage for src/lektor_ng/imagetools/exif.py: 99%
212 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-03 19:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-03 19:18 +0000
1"""Helper to access Exif info in images."""
3from __future__ import annotations
5import numbers
6from collections.abc import Callable, Mapping
7from contextlib import suppress
8from datetime import datetime
9from fractions import Fraction
10from functools import wraps
11from pathlib import Path
12from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
14import PIL.Image
16from ._compat import ExifTags, UnidentifiedImageError
17from .image_info import TiffOrientation
19if TYPE_CHECKING:
20 from typing import Literal
22 from _typeshed import SupportsRead
25def _combine_make(make: str | None, model: str | None) -> str:
26 make = make or ""
27 model = model or ""
28 if make and model.startswith(make):
29 return model
30 return f"{make} {model}".strip()
33# Interpretation of the Exif Flash tag value
34#
35# See: https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/flash.html
36#
37# Code copied from
38# https://github.com/ianare/exif-py/blob/51d5c5adf638219632dd755c6b7a4ce2535ada62/exifread/tags/exif.py#L318-L341
39#
40_EXIF_FLASH_VALUES = {
41 0: "Flash did not fire",
42 1: "Flash fired",
43 5: "Strobe return light not detected",
44 7: "Strobe return light detected",
45 9: "Flash fired, compulsory flash mode",
46 13: "Flash fired, compulsory flash mode, return light not detected",
47 15: "Flash fired, compulsory flash mode, return light detected",
48 16: "Flash did not fire, compulsory flash mode",
49 24: "Flash did not fire, auto mode",
50 25: "Flash fired, auto mode",
51 29: "Flash fired, auto mode, return light not detected",
52 31: "Flash fired, auto mode, return light detected",
53 32: "No flash function",
54 65: "Flash fired, red-eye reduction mode",
55 69: "Flash fired, red-eye reduction mode, return light not detected",
56 71: "Flash fired, red-eye reduction mode, return light detected",
57 73: "Flash fired, compulsory flash mode, red-eye reduction mode",
58 77: ("Flash fired, compulsory flash mode, red-eye reduction mode, return light not detected"),
59 79: ("Flash fired, compulsory flash mode, red-eye reduction mode, return light detected"),
60 89: "Flash fired, auto mode, red-eye reduction mode",
61 93: "Flash fired, auto mode, return light not detected, red-eye reduction mode",
62 95: "Flash fired, auto mode, return light detected, red-eye reduction mode",
63}
66def _to_flash_description(value: int) -> str:
67 desc = _EXIF_FLASH_VALUES.get(value)
68 if desc is None:
69 desc = f"{_EXIF_FLASH_VALUES[int(value) & 1]} ({value})"
70 return desc
73def _to_string(value: str) -> str:
74 # XXX: By spec, strings in EXIF tags are in ASCII, however some tools
75 # that handle EXIF tags support UTF-8.
76 # PIL seems to return strings decoded as iso-8859-1, which is rarely, if ever,
77 # right. Attempt re-decoding as UTF-8.
78 if not isinstance(value, str):
79 raise ValueError(f"Value {value!r} is not a string")
80 try:
81 return value.encode("iso-8859-1").decode("utf-8")
82 except UnicodeDecodeError:
83 return value
86# NB: Older versions of Pillow return (numerator, denominator) tuples
87# for EXIF rational numbers. New versions return a Fraction instance.
88ExifRational: TypeAlias = numbers.Rational | tuple[int, int]
89ExifReal: TypeAlias = numbers.Real | tuple[int, int]
92def _to_rational(value: ExifRational) -> numbers.Rational:
93 # NB: Older versions of Pillow return (numerator, denominator) tuples
94 # for EXIF rational numbers. New versions return a Fraction instance.
95 if isinstance(value, numbers.Rational):
96 return value
97 if isinstance(value, tuple) and len(value) == 2:
98 return Fraction(*value)
99 raise ValueError(f"Can not convert {value!r} to Rational")
102def _to_float(value: ExifReal) -> float:
103 if not isinstance(value, numbers.Real):
104 value = _to_rational(value)
105 return float(value)
108def _to_focal_length(value: ExifReal) -> str:
109 return f"{_to_float(value):g}mm"
112def _to_degrees(coords: tuple[ExifReal, ExifReal, ExifReal], hemisphere: Literal["E", "W", "N", "S"]) -> float:
113 degrees, minutes, seconds = map(_to_float, coords)
114 degrees = degrees + minutes / 60 + seconds / 3600
115 if hemisphere in {"S", "W"}:
116 degrees = -degrees
117 return degrees
120def _to_altitude(altitude: ExifReal, altitude_ref: Literal[b"\x00", b"\x01"]) -> float:
121 value = _to_float(altitude)
122 if altitude_ref == b"\x01":
123 value = -value
124 return value
127_T = TypeVar("_T")
130def _default_none(wrapped: Callable[[EXIFInfo], _T]) -> Callable[[EXIFInfo], _T | None]:
131 """Return ``None`` if wrapped getter raises a ``LookupError``.
133 This is a decorator intended for use on property getters for the EXIFInfo class.
135 If the wrapped getter raises a ``LookupError`` (as might happen if it tries to
136 access a non-existent value in one of the EXIF tables, the wrapper will return
137 ``None`` rather than propagating the exception.
139 """
141 @wraps(wrapped)
142 def wrapper(self: EXIFInfo) -> _T | None:
143 try:
144 return wrapped(self)
145 except LookupError:
146 return None
148 return wrapper
151class EXIFInfo:
152 """Adapt Exif tags to more user-friendly values.
154 This is an adapter that wraps a ``PIL.Image.Exif`` instance to make access to
155 certain Exif tags more user-friendly.
157 """
159 def __init__(self, exif: PIL.Image.Exif):
160 self._exif = exif
162 def __bool__(self) -> bool:
163 """True if any Exif data exists."""
164 return bool(self._exif)
166 def to_dict(self) -> dict[str, str | float | tuple[float, float] | None]:
167 """Return a dict containing the values of all known Exif tags."""
168 rv = {}
169 for key, value in self.__class__.__dict__.items():
170 if key[:1] != "_" and isinstance(value, property):
171 rv[key] = getattr(self, key)
172 return rv
174 @property
175 def _ifd0(self) -> Mapping[int, Any]:
176 """The main "Image File Directory" (IFD0).
178 This mapping contains the basic Exif tags applying to the main image. Keys are
179 the Exif tag number, values are typing strings, ints, floats, or rationals.
181 References
182 ----------
184 - https://www.media.mit.edu/pia/Research/deepview/exif.html#ExifTags
185 - https://www.awaresystems.be/imaging/tiff/tifftags/baseline.html
187 """
188 return self._exif
190 @property
191 def _exif_ifd(self) -> Mapping[int, Any]:
192 """The Exif SubIFD.
194 - https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif.html
195 """
196 return self._exif.get_ifd(ExifTags.IFD.Exif) # type: ignore[no-any-return]
198 @property
199 def _gpsinfo_ifd(self) -> Mapping[int, Any]:
200 """The GPS IFD
202 - https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/gps.html
203 """
204 # On older Pillow versions, get_ifd(GPSinfo) returns None.
205 # Prior to somewhere around Pillow 8.2.0, the GPS IFD was accessible at
206 # the top level. Try that first.
207 #
208 # https://pillow.readthedocs.io/en/stable/releasenotes/8.2.0.html#image-getexif-exif-and-gps-ifd
209 gps_ifd = self._exif.get(ExifTags.IFD.GPSInfo)
210 if isinstance(gps_ifd, dict):
211 return gps_ifd
212 return self._exif.get_ifd(ExifTags.IFD.GPSInfo) # type: ignore[no-any-return]
214 @property
215 @_default_none
216 def artist(self) -> str:
217 return _to_string(self._ifd0[ExifTags.Base.Artist])
219 @property
220 @_default_none
221 def copyright(self) -> str:
222 return _to_string(self._ifd0[ExifTags.Base.Copyright])
224 @property
225 @_default_none
226 def camera_make(self) -> str:
227 return _to_string(self._ifd0[ExifTags.Base.Make])
229 @property
230 @_default_none
231 def camera_model(self) -> str:
232 return _to_string(self._ifd0[ExifTags.Base.Model])
234 @property
235 def camera(self) -> str:
236 return _combine_make(self.camera_make, self.camera_model)
238 @property
239 @_default_none
240 def lens_make(self) -> str:
241 return _to_string(self._exif_ifd[ExifTags.Base.LensMake])
243 @property
244 @_default_none
245 def lens_model(self) -> str:
246 return _to_string(self._exif_ifd[ExifTags.Base.LensModel])
248 @property
249 def lens(self) -> str:
250 return _combine_make(self.lens_make, self.lens_model)
252 @property
253 @_default_none
254 def aperture(self) -> float:
255 return round(_to_float(self._exif_ifd[ExifTags.Base.ApertureValue]), 4)
257 @property
258 @_default_none
259 def f_num(self) -> float:
260 return round(_to_float(self._exif_ifd[ExifTags.Base.FNumber]), 4)
262 @property
263 @_default_none
264 def f(self) -> str:
265 value = _to_float(self._exif_ifd[ExifTags.Base.FNumber])
266 return f"ƒ/{value:g}"
268 @property
269 @_default_none
270 def exposure_time(self) -> str:
271 value = _to_rational(self._exif_ifd[ExifTags.Base.ExposureTime])
272 return f"{value.numerator}/{value.denominator}"
274 @property
275 @_default_none
276 def shutter_speed(self) -> str:
277 value = _to_float(self._exif_ifd[ExifTags.Base.ShutterSpeedValue])
278 return f"1/{2**value:.0f}"
280 @property
281 @_default_none
282 def focal_length(self) -> str:
283 return _to_focal_length(self._exif_ifd[ExifTags.Base.FocalLength])
285 @property
286 @_default_none
287 def focal_length_35mm(self) -> str:
288 return _to_focal_length(self._exif_ifd[ExifTags.Base.FocalLengthIn35mmFilm])
290 @property
291 @_default_none
292 def flash_info(self) -> str:
293 return _to_flash_description(self._exif_ifd[ExifTags.Base.Flash])
295 @property
296 @_default_none
297 def iso(self) -> float:
298 return _to_float(self._exif_ifd[ExifTags.Base.ISOSpeedRatings])
300 @property
301 def created_at(self) -> datetime | None:
302 date_tags = (
303 # XXX: GPSDateStamp includes just the date
304 # https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/gps/gpsdatestamp.html
305 (self._gpsinfo_ifd, ExifTags.GPS.GPSDateStamp),
306 # XXX: DateTimeOriginal is an EXIF tag, not and IFD0 tag
307 (self._ifd0, ExifTags.Base.DateTimeOriginal),
308 (self._exif_ifd, ExifTags.Base.DateTimeOriginal),
309 (self._exif_ifd, ExifTags.Base.DateTimeDigitized),
310 (self._ifd0, ExifTags.Base.DateTime),
311 )
312 for ifd, tag in date_tags:
313 with suppress(LookupError, ValueError):
314 return datetime.strptime(ifd[tag], "%Y:%m:%d %H:%M:%S")
315 return None
317 @property
318 @_default_none
319 def longitude(self) -> float:
320 gpsinfo_ifd = self._gpsinfo_ifd
321 return _to_degrees(
322 gpsinfo_ifd[ExifTags.GPS.GPSLongitude],
323 gpsinfo_ifd[ExifTags.GPS.GPSLongitudeRef],
324 )
326 @property
327 @_default_none
328 def latitude(self) -> float:
329 gpsinfo_ifd = self._gpsinfo_ifd
330 return _to_degrees(
331 gpsinfo_ifd[ExifTags.GPS.GPSLatitude],
332 gpsinfo_ifd[ExifTags.GPS.GPSLatitudeRef],
333 )
335 @property
336 @_default_none
337 def altitude(self) -> float:
338 gpsinfo_ifd = self._gpsinfo_ifd
339 value = _to_float(gpsinfo_ifd[ExifTags.GPS.GPSAltitude])
340 ref = gpsinfo_ifd.get(ExifTags.GPS.GPSAltitudeRef)
341 if ref == b"\x01":
342 value = -value
343 return value
345 @property
346 def location(self) -> tuple[float, float] | None:
347 lat = self.latitude
348 long = self.longitude
349 if lat is not None and long is not None:
350 return (lat, long)
351 return None
353 @property
354 @_default_none
355 def documentname(self) -> str:
356 return _to_string(self._ifd0[ExifTags.Base.DocumentName])
358 @property
359 @_default_none
360 def description(self) -> str:
361 return _to_string(self._ifd0[ExifTags.Base.ImageDescription])
363 @property
364 def is_rotated(self) -> bool:
365 """Return if the image is rotated according to the Orientation header.
367 The Orientation header in EXIF stores an integer value between
368 1 and 8, where the values 5-8 represent "portrait" orientations
369 (rotated 90deg left, right, and mirrored versions of those), i.e.,
370 the image is rotated.
371 """
372 try:
373 orientation = TiffOrientation(self._ifd0[ExifTags.Base.Orientation])
374 except (LookupError, ValueError):
375 return False
376 return orientation.is_transposed
379def read_exif(source: str | Path | SupportsRead[bytes]) -> EXIFInfo:
380 """Reads exif data from an image file."""
381 try:
382 with PIL.Image.open(source) as image:
383 exif = image.getexif()
384 except UnidentifiedImageError:
385 exif = PIL.Image.Exif()
386 return EXIFInfo(exif)