Source code for madam.raw
"""
Raw camera image processor using rawpy (LibRaw).
The optional ``raw`` dependency group must be installed::
uv sync --extra raw
"""
from __future__ import annotations
import datetime
import io
from collections.abc import Mapping
from fractions import Fraction
from typing import IO, Any
import piexif
from madam.core import Asset, MetadataProcessor, OperatorError, Processor, UnsupportedFormatError, operator
from madam.mime import MimeType
_MIME_TYPE_TO_PIL_FORMAT: dict[str, str] = {
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'image/tiff': 'TIFF',
}
_TIFF_MAGIC = (b'II\x2a\x00', b'MM\x00\x2a')
def _rational_to_float(value: Any) -> float:
"""Convert a piexif rational (numerator, denominator) tuple to float."""
num, den = value
return float(Fraction(num, den)) if den else 0.0
[docs]
class RawMetadataProcessor(MetadataProcessor):
"""
Reads EXIF metadata from raw camera image files (DNG, CR2, NEF, etc.)
using piexif.
Raw files embed EXIF data in their TIFF structure. This processor
extracts camera make/model, shooting parameters (ISO, exposure, f-number,
focal length), and capture timestamp, storing them under the ``'exif'``
format key — the same schema used by
:class:`~madam.exif.ExifMetadataProcessor` for JPEG/WebP.
Because piexif does not support inserting EXIF into TIFF/DNG files,
:meth:`combine` raises :class:`~madam.core.UnsupportedFormatError`.
:meth:`strip` returns the file unchanged (EXIF is integral to the raw
TIFF structure).
.. versionadded:: 1.0
"""
supported_mime_types: frozenset = frozenset({MimeType('image/x-raw')})
[docs]
def __init__(self, config: Mapping[str, Any] | None = None) -> None:
super().__init__(config)
@property
def formats(self) -> frozenset:
return frozenset({'exif'})
# ------------------------------------------------------------------
# Internal helper
# ------------------------------------------------------------------
@staticmethod
def _extract(raw_bytes: bytes) -> dict[str, Any]:
"""Return EXIF camera parameters extracted from *raw_bytes* via piexif."""
try:
meta = piexif.load(raw_bytes)
except Exception:
return {}
result: dict[str, Any] = {}
ifd0 = meta.get('0th', {})
make = ifd0.get(piexif.ImageIFD.Make)
if make:
result['camera.manufacturer'] = make.rstrip(b'\x00').decode('utf-8', errors='replace')
model = ifd0.get(piexif.ImageIFD.Model)
if model:
result['camera.model'] = model.rstrip(b'\x00').decode('utf-8', errors='replace')
exif_ifd = meta.get('Exif', {})
iso = exif_ifd.get(piexif.ExifIFD.ISOSpeedRatings)
if iso:
result['iso_speed'] = float(iso)
for tag, key in (
(piexif.ExifIFD.ExposureTime, 'exposure_time'),
(piexif.ExifIFD.FNumber, 'fnumber'),
(piexif.ExifIFD.FocalLength, 'focal_length'),
):
raw_val = exif_ifd.get(tag)
if raw_val:
val = _rational_to_float(raw_val)
if val > 0:
result[key] = val
dt_orig = exif_ifd.get(piexif.ExifIFD.DateTimeOriginal)
if dt_orig:
try:
dt = datetime.datetime.strptime(dt_orig.decode('utf-8'), '%Y:%m:%d %H:%M:%S')
result['created_at'] = dt.isoformat()
except (ValueError, UnicodeDecodeError):
pass
return result
# ------------------------------------------------------------------
# MetadataProcessor interface
# ------------------------------------------------------------------
[docs]
def read(self, file: IO) -> Mapping[str, Mapping]:
"""
Extract EXIF metadata from a raw camera image file.
:return: ``{'exif': {key: value, ...}}`` or ``{}`` if no EXIF data is
found.
:raises UnsupportedFormatError: if *file* is not a TIFF-based raw image.
"""
header = file.read(4)
file.seek(0)
if header not in _TIFF_MAGIC:
raise UnsupportedFormatError('Not a TIFF-based raw image')
data = file.read()
file.seek(0)
exif_data = self._extract(data)
return {'exif': exif_data} if exif_data else {}
[docs]
def strip(self, file: IO) -> IO:
"""
Return the file unchanged.
EXIF data is integral to the raw TIFF structure; removing it would
corrupt the file for most raw processors.
"""
data = file.read()
file.seek(0)
return io.BytesIO(data)
[docs]
def combine(self, file: IO, metadata: Mapping) -> IO:
"""
Not supported — piexif cannot insert EXIF into TIFF/DNG files.
:raises UnsupportedFormatError: always.
"""
raise UnsupportedFormatError('Writing EXIF to raw camera files is not supported')
[docs]
class RawImageProcessor(Processor):
"""
Represents a processor that handles raw camera image formats (DNG, CR2,
NEF, ARW, and any other format supported by LibRaw).
Reading and decoding require the `rawpy <https://letmaik.github.io/rawpy/>`_
package, which is a Python binding for LibRaw. Pillow is used to encode the
decoded image into the requested output format.
Install the optional ``raw`` extra to get both dependencies::
pip install madam[raw]
.. versionadded:: 0.24
"""
[docs]
def __init__(self, config: Mapping[str, Any] | None = None) -> None:
"""
Initializes a new ``RawImageProcessor``.
:param config: Mapping with settings.
"""
super().__init__(config)
@property
def supported_mime_types(self) -> frozenset:
return frozenset({'image/x-raw'})
[docs]
def can_read(self, file: IO) -> bool:
try:
import rawpy
except ImportError:
return False
data = file.read()
file.seek(0)
try:
with rawpy.imread(io.BytesIO(data)):
return True
except rawpy.LibRawError: # type: ignore[attr-defined]
return False
[docs]
def read(self, file: IO) -> Asset:
"""
Reads a raw camera image file and returns an :class:`~madam.core.Asset`.
The essence contains the original raw bytes unchanged. The ``width``
and ``height`` metadata attributes reflect the full sensor dimensions
before demosaicing.
:param file: Readable binary file-like object containing raw image data
:type file: IO
:return: Asset with ``mime_type='image/x-raw'``, ``width``, and ``height``
:rtype: Asset
"""
try:
import rawpy
except ImportError as e:
raise OperatorError('rawpy is required for reading raw images; install the raw extra') from e
raw_bytes = file.read()
with rawpy.imread(io.BytesIO(raw_bytes)) as raw:
width = raw.sizes.width
height = raw.sizes.height
extra: dict[str, Any] = {}
try:
meta_by_format = RawMetadataProcessor().read(io.BytesIO(raw_bytes))
extra.update(meta_by_format)
except UnsupportedFormatError:
pass
return Asset._from_bytes(raw_bytes, mime_type='image/x-raw', width=width, height=height, **extra)
[docs]
@operator
def decode(self, asset: Asset, mime_type: str = 'image/png') -> Asset:
"""
Demosaics a raw camera image and returns the result as a standard
raster image asset.
Demosaicing is performed with LibRaw using automatic white balance.
The result is encoded into the format specified by *mime_type*.
:param asset: Raw image asset to demosaic
:type asset: Asset
:param mime_type: MIME type of the output image (``'image/png'``,
``'image/jpeg'``, or ``'image/tiff'``)
:type mime_type: str
:return: Decoded raster image asset
:rtype: Asset
:raises OperatorError: if *mime_type* is not supported
"""
try:
import rawpy
except ImportError as e:
raise OperatorError('rawpy is required for decoding raw images; install the raw extra') from e
import PIL.Image
pil_format = _MIME_TYPE_TO_PIL_FORMAT.get(mime_type)
if pil_format is None:
raise OperatorError(f'Unsupported MIME type for raw decode: {mime_type!r}')
with rawpy.imread(io.BytesIO(asset.essence.read())) as raw:
rgb_array = raw.postprocess(use_camera_wb=True, no_auto_bright=False)
pil_image = PIL.Image.fromarray(rgb_array)
width, height = pil_image.size
buf = io.BytesIO()
pil_image.save(buf, format=pil_format)
buf.seek(0)
return Asset(essence=buf, mime_type=mime_type, width=width, height=height)