# Copyright (C) 2024 Apple Inc. All Rights Reserved. import logging from pathlib import Path from typing import Any, Dict, List, Tuple, Union import numpy as np from PIL import ExifTags, Image, TiffTags LOGGER = logging.getLogger(__name__) def extract_exif(img_pil: Image) -> Dict[str, Any]: """Return exif information as a dictionary. Args: ---- img_pil: A Pillow image. Returns: ------- A dictionary with extracted EXIF information. """ # Get full exif description from get_ifd(0x8769): # cf https://pillow.readthedocs.io/en/stable/releasenotes/8.2.0.html#image-getexif-exif-and-gps-ifd img_exif = img_pil.getexif().get_ifd(0x8769) exif_dict = {ExifTags.TAGS[k]: v for k, v in img_exif.items() if k in ExifTags.TAGS} tiff_tags = img_pil.getexif() tiff_dict = { TiffTags.TAGS_V2[k].name: v for k, v in tiff_tags.items() if k in TiffTags.TAGS_V2 } return {**exif_dict, **tiff_dict} def fpx_from_f35(width: float, height: float, f_mm: float = 50) -> float: """Convert a focal length given in mm (35mm film equivalent) to pixels.""" return f_mm * np.sqrt(width**2.0 + height**2.0) / np.sqrt(36**2 + 24**2) def load_rgb( path: Union[Path, str], auto_rotate: bool = True, remove_alpha: bool = True ) -> Tuple[np.ndarray, List[bytes], float]: """Load an RGB image. Args: ---- path: The url to the image to load. auto_rotate: Rotate the image based on the EXIF data, default is True. remove_alpha: Remove the alpha channel, default is True. Returns: ------- img: The image loaded as a numpy array. icc_profile: The color profile of the image. f_px: The optional focal length in pixels, extracting from the exif data. """ LOGGER.debug(f"Loading image {path} ...") path = Path(path) if path.suffix.lower() in [".heic"]: heif_file = pillow_heif.open_heif(path, convert_hdr_to_8bit=True) img_pil = heif_file.to_pillow() else: img_pil = Image.open(path) img_exif = extract_exif(img_pil) icc_profile = img_pil.info.get("icc_profile", None) # Rotate the image. if auto_rotate: exif_orientation = img_exif.get("Orientation", 1) if exif_orientation == 3: img_pil = img_pil.transpose(Image.ROTATE_180) elif exif_orientation == 6: img_pil = img_pil.transpose(Image.ROTATE_270) elif exif_orientation == 8: img_pil = img_pil.transpose(Image.ROTATE_90) elif exif_orientation != 1: LOGGER.warning(f"Ignoring image orientation {exif_orientation}.") img = np.array(img_pil) # Convert to RGB if single channel. if img.ndim < 3 or img.shape[2] == 1: img = np.dstack((img, img, img)) if remove_alpha: img = img[:, :, :3] LOGGER.debug(f"\tHxW: {img.shape[0]}x{img.shape[1]}") # Extract the focal length from exif data. f_35mm = img_exif.get( "FocalLengthIn35mmFilm", img_exif.get( "FocalLenIn35mmFilm", img_exif.get("FocalLengthIn35mmFormat", None) ), ) if f_35mm is not None and f_35mm > 0: LOGGER.debug(f"\tfocal length @ 35mm film: {f_35mm}mm") f_px = fpx_from_f35(img.shape[1], img.shape[0], f_35mm) else: f_px = None return img, icc_profile, f_px