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'.

Note

The baseline image must be in linear light space (not gamma-encoded). The caller is responsible for applying EOTF conversion before calling this function if the input is gamma-encoded (e.g., sRGB).

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'.

    Note:
        The baseline image must be in linear light space (not gamma-encoded).
        The caller is responsible for applying EOTF conversion before calling
        this function if the input is gamma-encoded (e.g., sRGB).

    See Also:
        - `hdr_to_gainmap`: Inverse operation, create gainmap from HDR.
    """
    baseline = data["baseline"].astype(np.float32) / 255.0  # Normalize to [0, 1]
    gainmap = data["gainmap"].astype(np.float32) / 255.0
    metadata = data["metadata"]

    # Resize gainmap to match baseline if needed
    h, w = baseline.shape[:2]
    if gainmap.shape[:2] != (h, w):
        # Use Pillow for resizing: convert float32 [0,1] -> uint8 [0,255] -> resize -> back to float32
        gainmap_uint8 = np.clip(gainmap * 255.0, 0, 255).astype(np.uint8)

        # Handle 2D grayscale and 3D RGB arrays
        if gainmap_uint8.ndim == 2:
            pil_image = Image.fromarray(gainmap_uint8, mode="L")
        else:
            pil_image = Image.fromarray(gainmap_uint8, mode="RGB")

        # Resize using bilinear interpolation (equivalent to cv2.INTER_LINEAR)
        pil_image_resized = pil_image.resize((w, h), Image.BILINEAR)

        # Convert back to float32 [0,1]
        gainmap = np.array(pil_image_resized, dtype=np.float32) / 255.0

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

    # Extract metadata (convert to arrays for broadcasting)
    gainmap_min = np.array(metadata["gainmap_min"], dtype=np.float32)
    gainmap_max = np.array(metadata["gainmap_max"], dtype=np.float32)
    gainmap_gamma = np.array(metadata["gainmap_gamma"], dtype=np.float32)
    baseline_offset = np.array(metadata["baseline_offset"], dtype=np.float32)
    alternate_offset = np.array(metadata["alternate_offset"], dtype=np.float32)

    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)

    # Reconstruct alternate (HDR) image
    hdr_linear = gainmap_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,
    )