Skip to content

Conversion

hdrconv.convert

HDR conversion algorithms.

This module provides functions for converting between HDR formats and applying color space and transfer function transformations:

  • Gainmap conversion: gainmap_to_hdr, hdr_to_gainmap
  • Apple HEIC conversion: apple_heic_to_hdr
  • Color space conversion: convert_color_space
  • Transfer functions: apply_pq, inverse_pq

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): None.
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): None.

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

        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.04,
                base_image_channel * 0.077,
                np.power((base_image_channel + 0.052) * 0.948, 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=None,
    )

gainmap_to_hdr(data)

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

Returns: HDRImage dict with the following keys: - data (np.ndarray): Linear HDR array, float32, shape (H, W, 3). - transfer_function (str): Always 'linear'.

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,
) -> 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.
    Returns:
        HDRImage dict with the following keys:
        - ``data`` (np.ndarray): Linear HDR array, float32, shape (H, W, 3).
        - ``transfer_function`` (str): Always 'linear'.

    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(e)
    if linear_baseline is None:
        linear_baseline = colour.eotf(baseline, function="sRGB")

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

    # 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
    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,
                )
            except Exception as e:
                warnings.warn(e)
        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)

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

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.

required
baseline Optional[ndarray]

Optional pre-computed baseline (SDR) image. 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 match the specified color_space.

None
gamma float

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

1.0

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.
        baseline: Optional pre-computed baseline (SDR) image.
            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 match the specified color_space.
        gamma: Gainmap gamma parameter for encoding.
            Higher values compress highlights. Default: 1.0.

    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.
    """
    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 = (gainmap_norm * 255).astype(np.uint8)

    baseline = colour.eotf_inverse(baseline, function="sRGB")
    baseline_uint8 = (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,
    )