Skip to content

Conversion

hdrconv.convert

HDR conversion algorithms.

This module provides functions for converting between HDR formats:

  • Gainmap conversion: gainmap_to_hdr, hdr_to_gainmap
  • Apple HEIC conversion: apple_heic_to_hdr

Transfer function encoding/decoding (e.g. PQ) is handled with the colour package, e.g. colour.eotf(data, 'ITU-R BT.2100 PQ') and colour.eotf_inverse(data, 'ITU-R BT.2100 PQ') as shown in examples/.

apple_heic_to_hdr(data)

Convert Apple HEIC gain map data to linear HDR.

Applies Apple's proprietary gain map formula to reconstruct the HDR image from the SDR base and single-channel gain map.

The reconstruction formula is

hdr_rgb = sdr_rgb * (1.0 + (headroom - 1.0) * gainmap)

Where all values are in linear light space.

Parameters:

Name Type Description Default
data AppleHeicData

AppleHeicData dict containing: - base: SDR image, uint8, shape (H, W, 3), Display P3. - gainmap: Gain map, uint8, shape (H, W, 1). - headroom: Peak luminance multiplier.

required

Returns:

Type Description
HDRImage

HDRImage dict with the following keys:

HDRImage
  • data (np.ndarray): Linear HDR array, float32, shape (H, W, 3).
HDRImage
  • color_space (str): 'p3' (Display P3, Apple's default).
HDRImage
  • transfer_function (str): 'linear'.
HDRImage
  • icc_profile (bytes | None): Source base-image ICC profile.
Note

The gain map is upscaled from 1/4 resolution using bilinear interpolation. Both base image (sRGB transfer) and gain map (Rec. 709 transfer) are linearized before applying the formula.

See Also
  • read_apple_heic: Read AppleHeicData from HEIC file.
Source code in src/hdrconv/convert/apple.py
def apple_heic_to_hdr(data: AppleHeicData) -> HDRImage:
    """Convert Apple HEIC gain map data to linear HDR.

    Applies Apple's proprietary gain map formula to reconstruct the HDR image
    from the SDR base and single-channel gain map.

    The reconstruction formula is:
        hdr_rgb = sdr_rgb * (1.0 + (headroom - 1.0) * gainmap)

    Where all values are in linear light space.

    Args:
        data: AppleHeicData dict containing:
            - `base`: SDR image, uint8, shape (H, W, 3), Display P3.
            - `gainmap`: Gain map, uint8, shape (H, W, 1).
            - `headroom`: Peak luminance multiplier.

    Returns:
        HDRImage dict with the following keys:
        - ``data`` (np.ndarray): Linear HDR array, float32, shape (H, W, 3).
        - ``color_space`` (str): 'p3' (Display P3, Apple's default).
        - ``transfer_function`` (str): 'linear'.
        - ``icc_profile`` (bytes | None): Source base-image ICC profile.

    Note:
        The gain map is upscaled from 1/4 resolution using bilinear interpolation.
        Both base image (sRGB transfer) and gain map (Rec. 709 transfer) are
        linearized before applying the formula.

    See Also:
        - `read_apple_heic`: Read AppleHeicData from HEIC file.
    """

    def apply_gain_map(
        base_image: np.ndarray, gain_map: np.ndarray, headroom: float
    ) -> np.ndarray:
        if base_image is None or gain_map is None:
            raise ValueError("Both base_image and gain_map must be provided.")

        if gain_map.ndim == 3 and gain_map.shape[-1] == 1:
            gain_map = gain_map[..., 0]

        gain_map_resized = np.array(
            Image.fromarray(gain_map).resize(
                (base_image.shape[1], base_image.shape[0]), Image.BICUBIC
            )
        )

        gain_map_norm = gain_map_resized.astype(np.float32) / 255.0

        def rec709_to_linear(gain_map_channel):
            return np.where(
                gain_map_channel <= 0.08145,
                gain_map_channel / 4.5,
                np.power((gain_map_channel + 0.099) / 1.099, 1 / 0.45),
            )

        def srgb_to_linear(base_image_channel):
            return np.where(
                base_image_channel <= 0.04045,
                base_image_channel / 12.92,
                np.power((base_image_channel + 0.055) / 1.055, 2.4),
            )

        gain_map_linear = rec709_to_linear(gain_map_norm)
        # gain_map_linear = np.clip(gain_map_linear, 0.0, 1.0)

        base_image_norm = base_image.astype(np.float32) / 255.0
        base_image_linear = srgb_to_linear(base_image_norm)
        # base_image_linear = np.clip(base_image_linear, 0.0, 1.0)

        hdr_image_linear = base_image_linear * (
            1.0 + (headroom - 1.0) * gain_map_linear[..., np.newaxis]
        )
        # hdr_image_linear = np.clip(hdr_image_linear, 0.0, None)
        return hdr_image_linear

    hdr_linear = apply_gain_map(data["base"], data["gainmap"], data["headroom"])

    return HDRImage(
        data=hdr_linear,
        color_space="p3",  # Apple uses Display P3
        transfer_function="linear",
        icc_profile=data.get("icc_profile"),
    )

gainmap_to_hdr(data, gainmap_resize_method='shepard')

Convert ISO 21496-1 Gainmap to linear HDR image.

Applies the gainmap to the baseline image to reconstruct the alternate (HDR) representation using the ISO 21496-1 formula:

  • G' = (G^(1/gamma)) * (max - min) + min
  • L = 2^G'
  • HDR = L * (baseline + baseline_offset) - alternate_offset

Parameters:

Name Type Description Default
data GainmapImage

GainmapImage dict containing baseline, gainmap, and metadata.

required
gainmap_resize_method GainmapResizeMethod

Method used when gainmap and baseline dimensions differ. "shepard" matches libultrahdr's 4-neighbour inverse-distance weighting.

'shepard'

Returns: HDRImage dict with the following keys: - data (np.ndarray): Linear HDR array, float32, shape (H, W, 3). - transfer_function (str): Always 'linear'. - icc_profile (bytes | None): ICC profile describing the working color space of the reconstruction: baseline_icc when use_base_colour_space is True, otherwise gainmap_icc when the baseline was converted to the alternate space (falling back to baseline_icc if the conversion was skipped or failed).

See Also
  • hdr_to_gainmap: Inverse operation, create gainmap from HDR.
Source code in src/hdrconv/convert/gainmap.py
def gainmap_to_hdr(
    data: GainmapImage,
    gainmap_resize_method: GainmapResizeMethod = "shepard",
) -> HDRImage:
    """Convert ISO 21496-1 Gainmap to linear HDR image.

    Applies the gainmap to the baseline image to reconstruct the alternate
    (HDR) representation using the ISO 21496-1 formula:

    - G' = (G^(1/gamma)) * (max - min) + min
    - L = 2^G'
    - HDR = L * (baseline + baseline_offset) - alternate_offset

    Args:
        data: GainmapImage dict containing baseline, gainmap, and metadata.
        gainmap_resize_method: Method used when gainmap and baseline dimensions differ.
            ``"shepard"`` matches libultrahdr's 4-neighbour inverse-distance weighting.
    Returns:
        HDRImage dict with the following keys:
        - ``data`` (np.ndarray): Linear HDR array, float32, shape (H, W, 3).
        - ``transfer_function`` (str): Always 'linear'.
        - ``icc_profile`` (bytes | None): ICC profile describing the working
          color space of the reconstruction: ``baseline_icc`` when
          ``use_base_colour_space`` is True, otherwise ``gainmap_icc`` when the
          baseline was converted to the alternate space (falling back to
          ``baseline_icc`` if the conversion was skipped or failed).

    See Also:
        - `hdr_to_gainmap`: Inverse operation, create gainmap from HDR.
    """

    # Linearize baseline
    baseline = _normalize_sample_array(
        data["baseline"], data.get("baseline_bit_depth"), "baseline"
    )
    icc_source = data.get("baseline_icc")
    linear_baseline = None
    if icc_source:
        try:
            linear_baseline = linearize_array_with_icc(data["baseline_icc"], baseline)
        except Exception as e:
            warnings.warn(
                "Failed to linearize baseline with its ICC profile "
                f"({type(e).__name__}: {e}); falling back to sRGB EOTF.",
                stacklevel=2,
            )
    if linear_baseline is None:
        linear_baseline = colour.eotf(baseline, function="sRGB").astype(np.float32)

    gainmap = _normalize_sample_array(
        data["gainmap"], data.get("gainmap_bit_depth"), "gainmap"
    )
    metadata = data["metadata"]

    # Resize gainmap to match baseline if needed
    h, w = baseline.shape[:2]
    if gainmap.shape[:2] != (h, w):
        gainmap = _resize_gainmap_array(gainmap, (w, h), gainmap_resize_method)

    # Ensure gainmap is 3-channel for calculations
    if gainmap.ndim == 2:
        gainmap = gainmap[:, :, np.newaxis]
    if gainmap.shape[2] == 1:
        gainmap = np.repeat(gainmap, 3, axis=2)

    # TODO: Move metadata channel normalization to a shared helper across I/O paths.
    gainmap_min = _as_triplet(metadata["gainmap_min"], "gainmap_min")
    gainmap_max = _as_triplet(metadata["gainmap_max"], "gainmap_max")
    gainmap_gamma = _as_triplet(metadata["gainmap_gamma"], "gainmap_gamma")
    baseline_offset = _as_triplet(metadata["baseline_offset"], "baseline_offset")
    alternate_offset = _as_triplet(metadata["alternate_offset"], "alternate_offset")

    gainmap = np.clip(gainmap, 0.0, 1.0)

    # Decode gainmap: apply gamma, scale, and offset
    gainmap_decoded = (gainmap ** (1 / gainmap_gamma)) * (
        gainmap_max - gainmap_min
    ) + gainmap_min

    # Convert to linear multiplier
    gainmap_linear = np.exp2(gainmap_decoded)

    # if use_base_colour_space is False, convert baseline to alternate space
    output_icc = data.get("baseline_icc")
    if not data["metadata"]["use_base_colour_space"]:
        linear_baseline_alt = None
        if data["baseline_icc"] is not None and data["gainmap_icc"] is not None:
            try:
                linear_baseline_alt = convert_array_with_icc_matrix(
                    source_icc=data["baseline_icc"],
                    target_icc=data["gainmap_icc"],
                    img_array=linear_baseline,
                )
                output_icc = data["gainmap_icc"]
            except Exception as e:
                warnings.warn(
                    "Failed to convert baseline to alternate color space "
                    f"({type(e).__name__}: {e}); keeping baseline color space.",
                    stacklevel=2,
                )
        if linear_baseline_alt is None:
            linear_baseline_alt = linear_baseline
        linear_baseline = linear_baseline_alt

    # Reconstruct alternate (HDR) image
    hdr_linear = gainmap_linear * (linear_baseline + baseline_offset) - alternate_offset
    hdr_linear = np.clip(hdr_linear, 0.0, None).astype(np.float32, copy=False)

    return HDRImage(
        data=hdr_linear,
        transfer_function="linear",
        icc_profile=output_icc,
    )

hdr_to_gainmap(hdr, baseline=None, icc_profile=None, gamma=1.0)

Convert linear HDR image to ISO 21496-1 Gainmap format.

Creates a gainmap by computing the log2 ratio between HDR and SDR images. If baseline is not provided, generates one by clipping HDR to [0, 1].

Parameters:

Name Type Description Default
hdr HDRImage

HDRImage dict with linear HDR data in any supported color space. transfer_function must be 'linear'.

required
baseline Optional[ndarray]

Optional pre-computed baseline (SDR) image in LINEAR light — do not pass sRGB/gamma-encoded data (the sRGB encoding is applied internally when storing the baseline). If None, generated by clipping HDR to [0, 1]. Expected format: float32, shape (H, W, 3), range [0, 1].

None
icc_profile Optional[bytes]

Optional ICC profile bytes to embed in output. Should describe the color space of hdr['data']. Defaults to hdr['icc_profile'] when omitted.

None
gamma float

Gainmap gamma parameter for encoding. Higher values compress highlights. Default: 1.0.

1.0

Raises:

Type Description
ValueError

If hdr['transfer_function'] is not 'linear'.

Returns:

Type Description
GainmapImage

GainmapImage dict containing:

GainmapImage
  • baseline (np.ndarray): SDR image, uint8, shape (H, W, 3).
GainmapImage
  • gainmap (np.ndarray): Gain map, uint8, shape (H, W, 3).
GainmapImage
  • metadata (GainmapMetadata): Computed transformation parameters.
GainmapImage
  • baseline_icc (bytes | None): Provided ICC profile.
GainmapImage
  • gainmap_icc (bytes | None): Provided ICC profile.
Note

Uses fixed offsets of 1/64 for both baseline and alternate to avoid division by zero in dark regions.

See Also
  • gainmap_to_hdr: Inverse operation, reconstruct HDR from gainmap.
  • write_21496: Write GainmapImage to ISO 21496-1 JPEG.
Source code in src/hdrconv/convert/gainmap.py
def hdr_to_gainmap(
    hdr: HDRImage,
    baseline: Optional[np.ndarray] = None,
    icc_profile: Optional[bytes] = None,
    gamma: float = 1.0,
) -> GainmapImage:
    """Convert linear HDR image to ISO 21496-1 Gainmap format.

    Creates a gainmap by computing the log2 ratio between HDR and SDR images.
    If baseline is not provided, generates one by clipping HDR to [0, 1].

    Args:
        hdr: HDRImage dict with linear HDR data in any supported color space.
            ``transfer_function`` must be ``'linear'``.
        baseline: Optional pre-computed baseline (SDR) image in LINEAR light —
            do not pass sRGB/gamma-encoded data (the sRGB encoding is applied
            internally when storing the baseline).
            If None, generated by clipping HDR to [0, 1].
            Expected format: float32, shape (H, W, 3), range [0, 1].
        icc_profile: Optional ICC profile bytes to embed in output.
            Should describe the color space of ``hdr['data']``. Defaults to
            ``hdr['icc_profile']`` when omitted.
        gamma: Gainmap gamma parameter for encoding.
            Higher values compress highlights. Default: 1.0.

    Raises:
        ValueError: If ``hdr['transfer_function']`` is not ``'linear'``.

    Returns:
        GainmapImage dict containing:
        - ``baseline`` (np.ndarray): SDR image, uint8, shape (H, W, 3).
        - ``gainmap`` (np.ndarray): Gain map, uint8, shape (H, W, 3).
        - ``metadata`` (GainmapMetadata): Computed transformation parameters.
        - ``baseline_icc`` (bytes | None): Provided ICC profile.
        - ``gainmap_icc`` (bytes | None): Provided ICC profile.

    Note:
        Uses fixed offsets of 1/64 for both baseline and alternate to
        avoid division by zero in dark regions.

    See Also:
        - `gainmap_to_hdr`: Inverse operation, reconstruct HDR from gainmap.
        - `write_21496`: Write GainmapImage to ISO 21496-1 JPEG.
    """
    if hdr["transfer_function"] != "linear":
        raise ValueError(
            "hdr_to_gainmap requires linear HDR data, but got "
            f"transfer_function={hdr['transfer_function']!r}. Linearize the data "
            "first (apply the matching EOTF) before calling hdr_to_gainmap."
        )

    if icc_profile is None:
        icc_profile = hdr.get("icc_profile")

    hdr_data = hdr["data"].astype(np.float32)

    # Generate baseline if not provided
    if baseline is None:
        baseline = hdr_data.copy()
        baseline = np.clip(baseline, 0.0, 1.0)

    # Compute alt headroom
    alt_headroom = np.log2(hdr_data.max() + 1e-6)
    # temporarily set a minimum headroom to avoid extremely small values that can't be encoded
    if alt_headroom <= 0.01:
        alt_headroom = 0.01

    # preset offset for both baseline and alternate = 1/64
    alt_offset = float(1 / 64)
    base_offset = float(1 / 64)

    ratio = (hdr_data + alt_offset) / (baseline + base_offset)
    ratio = np.clip(ratio, 1e-6, None)

    gainmap_log = np.log2(ratio)

    gainmap_min_val = np.min(gainmap_log, axis=(0, 1))
    gainmap_max_val = np.max(gainmap_log, axis=(0, 1))

    gainmap_norm = np.zeros_like(gainmap_log)
    for i in range(3):
        diff = gainmap_max_val[i] - gainmap_min_val[i]
        if diff == 0:
            gainmap_norm[:, :, i] = 0.0
        else:
            gainmap_norm[:, :, i] = (gainmap_log[:, :, i] - gainmap_min_val[i]) / diff
    gainmap_norm = np.clip(gainmap_norm, 0, 1)

    gainmap_norm = gainmap_norm**gamma

    gainmap_uint8 = np.round(gainmap_norm * 255).astype(np.uint8)

    baseline = colour.eotf_inverse(baseline, function="sRGB")
    baseline_uint8 = np.round(baseline * 255).astype(np.uint8)

    gainmap_min_val = tuple(gainmap_min_val.tolist())
    gainmap_max_val = tuple(gainmap_max_val.tolist())

    metadata = GainmapMetadata(
        minimum_version=0,
        writer_version=0,
        baseline_hdr_headroom=0.0,
        alternate_hdr_headroom=float(alt_headroom),
        is_multichannel=True,
        use_base_colour_space=True,
        gainmap_min=gainmap_min_val,
        gainmap_max=gainmap_max_val,
        gainmap_gamma=(gamma, gamma, gamma),
        baseline_offset=(base_offset, base_offset, base_offset),
        alternate_offset=(alt_offset, alt_offset, alt_offset),
    )

    return GainmapImage(
        baseline=baseline_uint8,
        gainmap=gainmap_uint8,
        metadata=metadata,
        baseline_icc=icc_profile,
        gainmap_icc=icc_profile,
        baseline_bit_depth=8,
        gainmap_bit_depth=8,
    )