Coverage for src/lektor_ng/imagetools/exif.py: 99%

212 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-05 15:26 +0000

1"""Helper to access Exif info in images.""" 

2 

3from __future__ import annotations 

4 

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 

13 

14import PIL.Image 

15 

16from ._compat import ExifTags, UnidentifiedImageError 

17from .image_info import TiffOrientation 

18 

19if TYPE_CHECKING: 

20 from typing import Literal 

21 

22 from _typeshed import SupportsRead 

23 

24 

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() 

31 

32 

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} 

64 

65 

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 

71 

72 

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 

84 

85 

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] 

90 

91 

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

100 

101 

102def _to_float(value: ExifReal) -> float: 

103 if not isinstance(value, numbers.Real): 

104 value = _to_rational(value) 

105 return float(value) 

106 

107 

108def _to_focal_length(value: ExifReal) -> str: 

109 return f"{_to_float(value):g}mm" 

110 

111 

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 

118 

119 

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 

125 

126 

127_T = TypeVar("_T") 

128 

129 

130def _default_none(wrapped: Callable[[EXIFInfo], _T]) -> Callable[[EXIFInfo], _T | None]: 

131 """Return ``None`` if wrapped getter raises a ``LookupError``. 

132 

133 This is a decorator intended for use on property getters for the EXIFInfo class. 

134 

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. 

138 

139 """ 

140 

141 @wraps(wrapped) 

142 def wrapper(self: EXIFInfo) -> _T | None: 

143 try: 

144 return wrapped(self) 

145 except LookupError: 

146 return None 

147 

148 return wrapper 

149 

150 

151class EXIFInfo: 

152 """Adapt Exif tags to more user-friendly values. 

153 

154 This is an adapter that wraps a ``PIL.Image.Exif`` instance to make access to 

155 certain Exif tags more user-friendly. 

156 

157 """ 

158 

159 def __init__(self, exif: PIL.Image.Exif): 

160 self._exif = exif 

161 

162 def __bool__(self) -> bool: 

163 """True if any Exif data exists.""" 

164 return bool(self._exif) 

165 

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 

173 

174 @property 

175 def _ifd0(self) -> Mapping[int, Any]: 

176 """The main "Image File Directory" (IFD0). 

177 

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. 

180 

181 References 

182 ---------- 

183 

184 - https://www.media.mit.edu/pia/Research/deepview/exif.html#ExifTags 

185 - https://www.awaresystems.be/imaging/tiff/tifftags/baseline.html 

186 

187 """ 

188 return self._exif 

189 

190 @property 

191 def _exif_ifd(self) -> Mapping[int, Any]: 

192 """The Exif SubIFD. 

193 

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] 

197 

198 @property 

199 def _gpsinfo_ifd(self) -> Mapping[int, Any]: 

200 """The GPS IFD 

201 

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] 

213 

214 @property 

215 @_default_none 

216 def artist(self) -> str: 

217 return _to_string(self._ifd0[ExifTags.Base.Artist]) 

218 

219 @property 

220 @_default_none 

221 def copyright(self) -> str: 

222 return _to_string(self._ifd0[ExifTags.Base.Copyright]) 

223 

224 @property 

225 @_default_none 

226 def camera_make(self) -> str: 

227 return _to_string(self._ifd0[ExifTags.Base.Make]) 

228 

229 @property 

230 @_default_none 

231 def camera_model(self) -> str: 

232 return _to_string(self._ifd0[ExifTags.Base.Model]) 

233 

234 @property 

235 def camera(self) -> str: 

236 return _combine_make(self.camera_make, self.camera_model) 

237 

238 @property 

239 @_default_none 

240 def lens_make(self) -> str: 

241 return _to_string(self._exif_ifd[ExifTags.Base.LensMake]) 

242 

243 @property 

244 @_default_none 

245 def lens_model(self) -> str: 

246 return _to_string(self._exif_ifd[ExifTags.Base.LensModel]) 

247 

248 @property 

249 def lens(self) -> str: 

250 return _combine_make(self.lens_make, self.lens_model) 

251 

252 @property 

253 @_default_none 

254 def aperture(self) -> float: 

255 return round(_to_float(self._exif_ifd[ExifTags.Base.ApertureValue]), 4) 

256 

257 @property 

258 @_default_none 

259 def f_num(self) -> float: 

260 return round(_to_float(self._exif_ifd[ExifTags.Base.FNumber]), 4) 

261 

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}" 

267 

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}" 

273 

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}" 

279 

280 @property 

281 @_default_none 

282 def focal_length(self) -> str: 

283 return _to_focal_length(self._exif_ifd[ExifTags.Base.FocalLength]) 

284 

285 @property 

286 @_default_none 

287 def focal_length_35mm(self) -> str: 

288 return _to_focal_length(self._exif_ifd[ExifTags.Base.FocalLengthIn35mmFilm]) 

289 

290 @property 

291 @_default_none 

292 def flash_info(self) -> str: 

293 return _to_flash_description(self._exif_ifd[ExifTags.Base.Flash]) 

294 

295 @property 

296 @_default_none 

297 def iso(self) -> float: 

298 return _to_float(self._exif_ifd[ExifTags.Base.ISOSpeedRatings]) 

299 

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 

316 

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 ) 

325 

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 ) 

334 

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 

344 

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 

352 

353 @property 

354 @_default_none 

355 def documentname(self) -> str: 

356 return _to_string(self._ifd0[ExifTags.Base.DocumentName]) 

357 

358 @property 

359 @_default_none 

360 def description(self) -> str: 

361 return _to_string(self._ifd0[ExifTags.Base.ImageDescription]) 

362 

363 @property 

364 def is_rotated(self) -> bool: 

365 """Return if the image is rotated according to the Orientation header. 

366 

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 

377 

378 

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)