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.
  • convert_color_space: Convert output to BT.2020 if needed.
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.
            - `convert_color_space`: Convert output to BT.2020 if needed.
    """

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

        gain_map_norm = gain_map_resized.astype(np.float32) / 255.0
        gain_map_linear = np.where(
            gain_map_norm <= 0.08145,
            gain_map_norm / 4.5,
            np.power((gain_map_norm + 0.099) / 1.099, 1 / 0.45),
        )
        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 = np.where(
            base_image_norm <= 0.04045,
            base_image_norm / 12.92,
            np.power((base_image_norm + 0.055) / 1.055, 2.4),
        )
        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,
    )

convert_color_space(image, source_space, target_space, clip=False)

Convert image between color spaces.

Transforms RGB values from one color space to another using chromatic adaptation and matrix transformations. Input must be in linear light.

Parameters:

Name Type Description Default
image ndarray

Linear RGB image data, float32, shape (H, W, 3). Values should be in linear light (not gamma-encoded).

required
source_space str

Source color space identifier. Options: 'bt709' (Rec. 709), 'p3' (Display P3), 'bt2020' (Rec. 2020).

required
target_space str

Target color space identifier. Options: 'bt709', 'p3', 'bt2020'.

required
clip bool

Whether to clip output to [0, inf). Default: False. Enable when negative values from gamut mapping are undesirable.

False

Returns:

Type Description
ndarray

Converted image in target color space, same shape as input.

ndarray

Values remain in linear light.

Note

If source_space equals target_space, returns input unchanged. Uses colour-science library for accurate color transformations.

See Also
  • apply_pq: Apply PQ transfer function after color space conversion.
  • gainmap_to_hdr: Includes color space conversion in HDR reconstruction.
Source code in src/hdrconv/convert/colorspace.py
def convert_color_space(
    image: np.ndarray, source_space: str, target_space: str, clip: bool = False
) -> np.ndarray:
    """Convert image between color spaces.

        Transforms RGB values from one color space to another using chromatic
        adaptation and matrix transformations. Input must be in linear light.

        Args:
            image: Linear RGB image data, float32, shape (H, W, 3).
                Values should be in linear light (not gamma-encoded).
            source_space: Source color space identifier.
                Options: 'bt709' (Rec. 709), 'p3' (Display P3), 'bt2020' (Rec. 2020).
            target_space: Target color space identifier.
                Options: 'bt709', 'p3', 'bt2020'.
            clip: Whether to clip output to [0, inf). Default: False.
                Enable when negative values from gamut mapping are undesirable.

        Returns:
            Converted image in target color space, same shape as input.
            Values remain in linear light.

        Note:
            If source_space equals target_space, returns input unchanged.
            Uses colour-science library for accurate color transformations.

        See Also:
            - `apply_pq`: Apply PQ transfer function after color space conversion.
            - `gainmap_to_hdr`: Includes color space conversion in HDR reconstruction.
    """
    space_map = {"bt709": "ITU-R BT.709", "p3": "DCI-P3", "bt2020": "ITU-R BT.2020"}

    if source_space == target_space:
        return image

    source_name = space_map.get(source_space, source_space)
    target_name = space_map.get(target_space, target_space)

    target_image = colour.RGB_to_RGB(
        image, input_colourspace=source_name, output_colourspace=target_name
    )

    if clip:
        target_image = np.clip(target_image, 0.0, None)
    return target_image

gainmap_to_hdr(data, baseline_color_space='p3', alt_color_space='p3', target_color_space='bt2020')

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
baseline_color_space str

Color space of baseline image. Options: 'bt709', 'p3', 'bt2020'. Default: 'p3'.

'p3'
alt_color_space str

Color space of alternate/HDR image. Options: 'bt709', 'p3', 'bt2020'. Default: 'p3'.

'p3'
target_color_space str

Target output color space. Options: 'bt709', 'p3', 'bt2020'. Default: 'bt2020'.

'bt2020'

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): Target color space string.
HDRImage
  • transfer_function (str): Always 'linear'.
HDRImage
  • icc_profile (bytes | None): Always None.
Note

The baseline image is assumed to be sRGB-encoded. The function automatically applies EOTF conversion to linear light before applying the gainmap formula.

See Also
  • hdr_to_gainmap: Inverse operation, create gainmap from HDR.
  • convert_color_space: For additional color space transformations.
Source code in src/hdrconv/convert/gainmap.py
def gainmap_to_hdr(
    data: GainmapImage,
    baseline_color_space: str = "p3",
    alt_color_space: str = "p3",
    target_color_space: str = "bt2020",
) -> 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.
            baseline_color_space: Color space of baseline image.
                Options: 'bt709', 'p3', 'bt2020'. Default: 'p3'.
            alt_color_space: Color space of alternate/HDR image.
                Options: 'bt709', 'p3', 'bt2020'. Default: 'p3'.
            target_color_space: Target output color space.
                Options: 'bt709', 'p3', 'bt2020'. Default: 'bt2020'.

        Returns:
            HDRImage dict with the following keys:
            - ``data`` (np.ndarray): Linear HDR array, float32, shape (H, W, 3).
            - ``color_space`` (str): Target color space string.
            - ``transfer_function`` (str): Always 'linear'.
            - ``icc_profile`` (bytes | None): Always None.

        Note:
            The baseline image is assumed to be sRGB-encoded. The function
            automatically applies EOTF conversion to linear light before
            applying the gainmap formula.

        See Also:
            - `hdr_to_gainmap`: Inverse operation, create gainmap from HDR.
            - `convert_color_space`: For additional color space transformations.
    """
    baseline = data["baseline"].astype(np.float32) / 255.0  # Normalize to [0, 1]
    baseline = colour.eotf(baseline, function="sRGB")
    gainmap = data["gainmap"].astype(np.float32) / 255.0
    metadata = data["metadata"]

    use_base_colour_space = metadata["use_base_colour_space"]
    if not use_base_colour_space:
        baseline = convert_color_space(
            baseline,
            source_space=baseline_color_space,
            target_space=alt_color_space,
        )
    else:
        gainmap = convert_color_space(
            gainmap,
            source_space=alt_color_space,
            target_space=baseline_color_space,
        )

    # 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

    # Color space conversion
    if not use_base_colour_space:
        hdr_linear = convert_color_space(
            hdr_linear, source_space=alt_color_space, target_space=target_color_space
        )
    else:
        hdr_linear = convert_color_space(
            hdr_linear,
            source_space=baseline_color_space,
            target_space=target_color_space,
        )

    hdr_linear = np.clip(hdr_linear, 0.0, None)

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

hdr_to_gainmap(hdr, baseline=None, color_space='bt709', 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
color_space str

Target color space for output. Options: 'bt709', 'p3', 'bt2020'. Default: 'bt709'.

'bt709'
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,
    color_space: str = "bt709",
    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].
            color_space: Target color space for output.
                Options: 'bt709', 'p3', 'bt2020'. Default: 'bt709'.
            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)

    # convert to target colour space
    hdr_data = convert_color_space(
        hdr_data, source_space=hdr["color_space"], target_space=color_space
    )

    hdr_data = np.clip(hdr_data, 0.0, None)

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

    # 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 = (gainmap_log - gainmap_min_val) / (gainmap_max_val - gainmap_min_val)
    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=1.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,
    )

apply_pq(linear_rgb)

Apply PQ (Perceptual Quantizer) transfer function.

Encodes linear light RGB values to PQ (SMPTE ST 2084) transfer function as specified in ITU-R BT.2100 for HDR content.

Parameters:

Name Type Description Default
linear_rgb ndarray

Linear RGB data, float32, shape (H, W, 3). Values should be normalized where 1.0 = 203 nits (reference white). Values above 1.0 represent HDR highlights up to ~49x (10000 nits).

required

Returns:

Type Description
ndarray

PQ-encoded data, float32, shape (H, W, 3), range [0, 1].

ndarray

Output is clipped to valid PQ range.

Note

Uses 203 nits as reference white (PQ specification). Linear value of 1.0 maps to ~58% in PQ code values.

See Also
  • inverse_pq: Decode PQ back to linear light.
  • write_22028_pq: Write PQ-encoded data to AVIF file.
Source code in src/hdrconv/convert/transfer.py
def apply_pq(linear_rgb: np.ndarray) -> np.ndarray:
    """Apply PQ (Perceptual Quantizer) transfer function.

        Encodes linear light RGB values to PQ (SMPTE ST 2084) transfer function
        as specified in ITU-R BT.2100 for HDR content.

        Args:
            linear_rgb: Linear RGB data, float32, shape (H, W, 3).
                Values should be normalized where 1.0 = 203 nits (reference white).
                Values above 1.0 represent HDR highlights up to ~49x (10000 nits).

        Returns:
            PQ-encoded data, float32, shape (H, W, 3), range [0, 1].
            Output is clipped to valid PQ range.

        Note:
            Uses 203 nits as reference white (PQ specification).
            Linear value of 1.0 maps to ~58% in PQ code values.

        See Also:
            - `inverse_pq`: Decode PQ back to linear light.
            - `write_22028_pq`: Write PQ-encoded data to AVIF file.
    """
    # Normalize to reference white (203 nits in PQ)
    pq_encoded = colour.models.eotf_inverse_BT2100_PQ(linear_rgb * 203.0)
    pq_encoded = np.clip(pq_encoded, 0.0, 1.0)
    return pq_encoded

inverse_pq(pq_encoded)

Decode PQ-encoded values to linear light RGB.

Applies the inverse PQ (SMPTE ST 2084) EOTF to convert PQ-encoded values back to linear light as specified in ITU-R BT.2100.

Parameters:

Name Type Description Default
pq_encoded ndarray

PQ-encoded data, float32, shape (H, W, 3), range [0, 1]. Values represent 0-10000 nits in PQ perceptual scale.

required

Returns:

Type Description
ndarray

Linear RGB data, float32, shape (H, W, 3).

ndarray

Normalized where 1.0 = 203 nits (reference white).

ndarray

HDR highlights may exceed 1.0 up to ~49x.

Note

Uses 203 nits as reference white (PQ specification). PQ code value of ~0.58 maps to linear 1.0.

See Also
  • apply_pq: Encode linear light to PQ.
  • read_22028_pq: Read PQ-encoded AVIF file.
Source code in src/hdrconv/convert/transfer.py
def inverse_pq(pq_encoded: np.ndarray) -> np.ndarray:
    """Decode PQ-encoded values to linear light RGB.

        Applies the inverse PQ (SMPTE ST 2084) EOTF to convert PQ-encoded
        values back to linear light as specified in ITU-R BT.2100.

        Args:
            pq_encoded: PQ-encoded data, float32, shape (H, W, 3), range [0, 1].
                Values represent 0-10000 nits in PQ perceptual scale.

        Returns:
            Linear RGB data, float32, shape (H, W, 3).
            Normalized where 1.0 = 203 nits (reference white).
            HDR highlights may exceed 1.0 up to ~49x.

        Note:
            Uses 203 nits as reference white (PQ specification).
            PQ code value of ~0.58 maps to linear 1.0.

        See Also:
            - `apply_pq`: Encode linear light to PQ.
            - `read_22028_pq`: Read PQ-encoded AVIF file.
    """
    linear_normalized = colour.models.eotf_BT2100_PQ(pq_encoded)
    linear_rgb = linear_normalized / 203.0
    return linear_rgb