Skip to content

I/O

hdrconv.io

HDR format I/O operations.

This module provides functions for reading and writing various HDR formats:

  • ISO 21496-1 (Adaptive Gainmap): read_21496, write_21496
  • ISO 22028-5 (PQ/HLG AVIF): read_22028_pq, write_22028_pq
  • Apple HEIC with gainmap: read_apple_heic
  • iOS HDR screenshot: read_ios_hdr_screenshot

read_21496(filepath)

Read ISO 21496-1 Gainmap JPEG file.

Parses a JPEG file containing an ISO 21496-1 compliant gainmap with Multi-Picture Format (MPF) container structure.

Parameters:

Name Type Description Default
filepath str

Path to the ISO 21496-1 JPEG file.

required

Returns:

Type Description
GainmapImage

GainmapImage dict containing:

GainmapImage
  • baseline (np.ndarray): SDR image, uint8, shape (H, W, 3), range [0, 255].
GainmapImage
  • gainmap (np.ndarray): Gain map, uint8, shape (H, W, 3) or (H, W, 1).
GainmapImage
  • metadata (GainmapMetadata): Transformation parameters including gamma, min/max values, offsets, and headroom.
GainmapImage
  • baseline_icc (bytes | None): ICC profile for baseline image.
GainmapImage
  • gainmap_icc (bytes | None): ICC profile for gainmap.

Raises:

Type Description
ValueError

If gainmap is not found in MPF container.

ValueError

If ISO 21496-1 metadata segment is missing.

Note

The file must contain a valid MPF structure with the gainmap as the secondary image and ISO 21496-1 metadata in an APP2 segment.

See Also
  • write_21496: Write GainmapImage to ISO 21496-1 format.
  • gainmap_to_hdr: Convert GainmapImage to linear HDR.
Source code in src/hdrconv/io/iso21496.py
def read_21496(filepath: str) -> GainmapImage:
    """Read ISO 21496-1 Gainmap JPEG file.

    Parses a JPEG file containing an ISO 21496-1 compliant gainmap with
    Multi-Picture Format (MPF) container structure.

    Args:
        filepath: Path to the ISO 21496-1 JPEG file.

    Returns:
        GainmapImage dict containing:
        - ``baseline`` (np.ndarray): SDR image, uint8, shape (H, W, 3), range [0, 255].
        - ``gainmap`` (np.ndarray): Gain map, uint8, shape (H, W, 3) or (H, W, 1).
        - ``metadata`` (GainmapMetadata): Transformation parameters including
            gamma, min/max values, offsets, and headroom.
        - ``baseline_icc`` (bytes | None): ICC profile for baseline image.
        - ``gainmap_icc`` (bytes | None): ICC profile for gainmap.

    Raises:
        ValueError: If gainmap is not found in MPF container.
        ValueError: If ISO 21496-1 metadata segment is missing.

    Note:
        The file must contain a valid MPF structure with the gainmap as
        the secondary image and ISO 21496-1 metadata in an APP2 segment.

    See Also:
        - `write_21496`: Write GainmapImage to ISO 21496-1 format.
        - `gainmap_to_hdr`: Convert GainmapImage to linear HDR.
    """
    with open(filepath, "rb") as f:
        raw_data = f.read()

    # 1. Split streams (Primary vs Gainmap) via MPF
    primary_data, gainmap_data = _split_mpf_container(raw_data)

    if not gainmap_data:
        raise ValueError("No gainmap found in container (MPF missing or invalid).")

    # 2. Decode Images
    # Suppress MPO-related warnings from Pillow when reading JPEG streams
    with warnings.catch_warnings():
        warnings.filterwarnings(
            "ignore",
            message="Image appears to be a malformed MPO file",
            category=UserWarning,
        )
        base_img = Image.open(io.BytesIO(primary_data)).convert("RGB")
        gain_img = Image.open(io.BytesIO(gainmap_data)).convert("RGB")

    base_arr = np.array(base_img)
    gain_arr = np.array(gain_img)

    # 3. Extract Metadata & ICC
    # Metadata usually lives in the Gainmap stream's APP2, but we check both.

    base_segments = list(_yield_jpeg_segments(primary_data))
    gain_segments = list(_yield_jpeg_segments(gainmap_data))

    base_icc = _extract_icc(base_segments)
    gain_icc = _extract_icc(gain_segments)

    iso_meta = None

    # Search for ISO 21496 metadata (Prioritize Gainmap stream)
    for segments in [gain_segments, base_segments]:
        for code, payload in segments:
            if code == APP2 and (
                payload.startswith(ISO21496_URN) or payload.startswith(ISO21496_URN_ALT)
            ):
                iso_meta = _parse_iso21496_metadata(payload)
                break
        if iso_meta:
            break

    if not iso_meta:
        raise ValueError("ISO 21496-1 metadata segment not found.")

    return GainmapImage(
        baseline=base_arr,
        gainmap=gain_arr,
        metadata=iso_meta,
        baseline_icc=base_icc,
        gainmap_icc=gain_icc,
    )

write_21496(data, filepath)

Write ISO 21496-1 Gainmap JPEG file.

Creates a JPEG file with ISO 21496-1 compliant gainmap structure using Multi-Picture Format (MPF) container.

Parameters:

Name Type Description Default
data GainmapImage

GainmapImage dict containing: - baseline: SDR image, uint8, shape (H, W, 3). - gainmap: Gain map, uint8, shape (H, W, 3) or (H, W, 1). - metadata: GainmapMetadata with transformation parameters. - baseline_icc: Optional ICC profile for baseline. - gainmap_icc: Optional ICC profile for gainmap.

required
filepath str

Output path for the JPEG file.

required

Raises:

Type Description
RuntimeError

If file writing fails.

Note

The output file structure places the baseline image first with an MPF index, followed by the gainmap with ISO 21496-1 metadata. JPEG quality is set to 95 with 4:4:4 chroma subsampling.

See Also
  • read_21496: Read ISO 21496-1 Gainmap JPEG.
  • hdr_to_gainmap: Convert HDR image to GainmapImage.
Source code in src/hdrconv/io/iso21496.py
def write_21496(data: GainmapImage, filepath: str) -> None:
    """Write ISO 21496-1 Gainmap JPEG file.

        Creates a JPEG file with ISO 21496-1 compliant gainmap structure using
        Multi-Picture Format (MPF) container.

        Args:
            data: GainmapImage dict containing:
                - ``baseline``: SDR image, uint8, shape (H, W, 3).
                - ``gainmap``: Gain map, uint8, shape (H, W, 3) or (H, W, 1).
                - ``metadata``: GainmapMetadata with transformation parameters.
                - ``baseline_icc``: Optional ICC profile for baseline.
                - ``gainmap_icc``: Optional ICC profile for gainmap.
            filepath: Output path for the JPEG file.

        Raises:
            RuntimeError: If file writing fails.

        Note:
            The output file structure places the baseline image first with an
            MPF index, followed by the gainmap with ISO 21496-1 metadata.
            JPEG quality is set to 95 with 4:4:4 chroma subsampling.

        See Also:
            - `read_21496`: Read ISO 21496-1 Gainmap JPEG.
            - `hdr_to_gainmap`: Convert HDR image to GainmapImage.
    """
    try:
        # 1. 编码 Gainmap 图像 (基础 JPEG 编码)
        gainmap_bytes_raw = _create_jpeg_bytes(data["gainmap"], data.get("gainmap_icc"))

        # 1.1 在 Gainmap 流中插入一个最小 MPF APP2(兼容性需要)
        gainmap_mpf_segment = _build_app2_segment(_build_mpf_minimal_payload(2))

        # 2. 构建 ISO 21496-1 元数据段 (APP2)
        #    标准建议将此元数据放在 Gainmap 图像流中
        iso_payload = _encode_iso21496_metadata(data["metadata"])
        iso_segment = _build_app2_segment(iso_payload)

        #    将 ISO 段插入到 Gainmap 的 SOI (0xFFD8) 之后
        gainmap_final = (
            gainmap_bytes_raw[:2]
            + gainmap_mpf_segment
            + iso_segment
            + gainmap_bytes_raw[2:]
        )

        # 3. 编码 Baseline 图像 (基础 JPEG 编码)
        primary_bytes_raw = _create_jpeg_bytes(
            data["baseline"], data.get("baseline_icc")
        )

        # 3.1 在 Primary 流中插入一个短 URN stub APP2(兼容性需要)
        # 该 stub 不是完整元数据,仅用于标记 ISO21496 容器。
        primary_stub_segment = _build_app2_segment(ISO21496_URN + b"\x00\x00\x00\x00")

        # 4. 构建 MPF 索引段 (APP2)
        #    MPF 位于 Baseline 图像中,用于指向文件末尾的 Gainmap

        #    A. 首先生成一个带有占位偏移量的 MPF payload,用于计算长度
        #       MPF 段放在 Primary stub 之后
        mpf_payload_temp = _build_mpf_payload(
            primary_size=len(primary_bytes_raw),  # 暂时的,稍后修正
            gainmap_size=len(gainmap_final),
            gainmap_offset=0,  # 占位
        )
        mpf_segment_temp = _build_app2_segment(mpf_payload_temp)

        #    B. 计算最终文件结构中的绝对位置
        #       Primary_Final_Len = Raw_Primary_Len + Stub_Seg_Len + MPF_Seg_Len
        total_primary_len = (
            len(primary_bytes_raw) + len(primary_stub_segment) + len(mpf_segment_temp)
        )

        #    C. 计算 Gainmap 的相对偏移量
        #       MPF 标准规定偏移量是相对于 MPF Header (即 'MM'/'II' 字节) 的位置
        #       base_file_offset = MPF marker 的文件偏移 + 8 (marker+len+"MPF\0")
        mpf_marker_offset = 2 + len(primary_stub_segment)
        mpf_base_file_offset = mpf_marker_offset + 8
        gainmap_relative_offset = total_primary_len - mpf_base_file_offset

        #    D. 重新生成包含正确 Primary 大小和 Gainmap 偏移量的 MPF payload
        mpf_payload_final = _build_mpf_payload(
            primary_size=total_primary_len,
            gainmap_size=len(gainmap_final),
            gainmap_offset=gainmap_relative_offset,
        )
        mpf_segment_final = _build_app2_segment(mpf_payload_final)

        # 5. 组装 Baseline 流 (插入 MPF)
        primary_final = (
            primary_bytes_raw[:2]
            + primary_stub_segment
            + mpf_segment_final
            + primary_bytes_raw[2:]
        )

        # 6. 拼接并写入文件 (Baseline + Gainmap)
        with open(filepath, "wb") as f:
            f.write(primary_final)
            f.write(gainmap_final)

    except Exception as e:
        raise RuntimeError(f"Failed to write ISO 21496-1 file: {filepath}") from e

read_22028_pq(filepath)

Read ISO 22028-5 PQ AVIF file.

Decodes an AVIF file encoded with Perceptual Quantizer (PQ) transfer function as specified in ISO 22028-5 and SMPTE ST 2084.

Parameters:

Name Type Description Default
filepath str

Path to the PQ AVIF file.

required

Returns:

Type Description
HDRImage

HDRImage dict containing:

HDRImage
  • data (np.ndarray): PQ-encoded array, float32, shape (H, W, 3), range [0, 1] representing 0-10000 nits.
HDRImage
  • color_space (str): Color primaries, typically 'bt2020'.
HDRImage
  • transfer_function (str): Always 'pq'.
HDRImage
  • icc_profile (bytes | None): Currently None (not extracted).
Note

Currently assumes BT.2020 color primaries and 10-bit decode range. Future versions may extract actual color metadata from AVIF.

See Also
  • write_22028_pq: Write HDR image to PQ AVIF format.
  • inverse_pq: Convert PQ-encoded data to linear light.
Source code in src/hdrconv/io/iso22028.py
def read_22028_pq(filepath: str) -> HDRImage:
    """Read ISO 22028-5 PQ AVIF file.

        Decodes an AVIF file encoded with Perceptual Quantizer (PQ) transfer
        function as specified in ISO 22028-5 and SMPTE ST 2084.

        Args:
            filepath: Path to the PQ AVIF file.

        Returns:
            HDRImage dict containing:
            - ``data`` (np.ndarray): PQ-encoded array, float32, shape (H, W, 3),
                range [0, 1] representing 0-10000 nits.
            - ``color_space`` (str): Color primaries, typically 'bt2020'.
            - ``transfer_function`` (str): Always 'pq'.
            - ``icc_profile`` (bytes | None): Currently None (not extracted).

        Note:
            Currently assumes BT.2020 color primaries and 10-bit decode range.
            Future versions may extract actual color metadata from AVIF.

        See Also:
            - `write_22028_pq`: Write HDR image to PQ AVIF format.
            - `inverse_pq`: Convert PQ-encoded data to linear light.
    """
    with open(filepath, "rb") as f:
        avif_bytes = f.read()
    image_array = avif_decode(avif_bytes, numthreads=-1)
    # Extract PQ-encoded array (normalized to [0, 1])
    # Currently hard-coded to 10-bit decode range.
    image_array = image_array / 1023.0
    # TODO: Extract actual color primaries and transfer from AVIF metadata
    # For now, assume BT.2020 PQ which is most common
    return HDRImage(
        data=image_array,
        color_space="bt2020",
        transfer_function="pq",
        icc_profile=None,
    )

write_22028_pq(data, filepath)

Write ISO 22028-5 PQ AVIF file.

Encodes an HDR image to AVIF format with Perceptual Quantizer (PQ) transfer function as specified in ISO 22028-5 and SMPTE ST 2084.

Parameters:

Name Type Description Default
data HDRImage

HDRImage dict with PQ-encoded data. Must contain: - data: float32 array, shape (H, W, 3), range [0, 1]. - color_space: Color primaries ('bt709', 'p3', 'bt2020'). - transfer_function: Transfer function ('pq', 'hlg', etc.).

required
filepath str

Output path for the AVIF file.

required
Note

Output is encoded at 10-bit depth with quality level 90. Color primaries and transfer characteristics are embedded in AVIF metadata.

See Also
  • read_22028_pq: Read PQ AVIF file.
  • apply_pq: Convert linear HDR to PQ-encoded values.
Source code in src/hdrconv/io/iso22028.py
def write_22028_pq(data: HDRImage, filepath: str) -> None:
    """Write ISO 22028-5 PQ AVIF file.

        Encodes an HDR image to AVIF format with Perceptual Quantizer (PQ)
        transfer function as specified in ISO 22028-5 and SMPTE ST 2084.

        Args:
            data: HDRImage dict with PQ-encoded data. Must contain:
                - ``data``: float32 array, shape (H, W, 3), range [0, 1].
                - ``color_space``: Color primaries ('bt709', 'p3', 'bt2020').
                - ``transfer_function``: Transfer function ('pq', 'hlg', etc.).
            filepath: Output path for the AVIF file.

        Note:
            Output is encoded at 10-bit depth with quality level 90.
            Color primaries and transfer characteristics are embedded in AVIF metadata.

        See Also:
            - `read_22028_pq`: Read PQ AVIF file.
            - `apply_pq`: Convert linear HDR to PQ-encoded values.
    """
    # Map color primaries to numeric codes
    primaries_map = {"bt709": 1, "bt2020": 9, "p3": 12}

    # Map transfer characteristics to numeric codes
    transfer_map = {"bt709": 1, "linear": 8, "pq": 16, "hlg": 18}

    primaries_code = primaries_map.get(data["color_space"], 9)
    transfer_code = transfer_map.get(data["transfer_function"], 16)

    np_array = np.clip(data["data"], 0, 1)
    # scale to [0, 1023]
    np_array = np_array * 1023.0
    np_array = np_array.astype(np.uint16)

    avif_bytes: bytes = avif_encode(
        np_array,
        level=90,
        speed=8,
        bitspersample=10,
        primaries=primaries_code,
        transfer=transfer_code,
        numthreads=-1,
    )

    # Write the AVIF bytes to the output file
    with open(filepath, "wb") as f:
        f.write(avif_bytes)

read_apple_heic(filepath)

Read Apple HEIC HDR file with gain map.

Extracts the base SDR image, HDR gain map, and headroom metadata from iPhone HEIC photos containing Apple's proprietary HDR format.

Parameters:

Name Type Description Default
filepath str

Path to the Apple HEIC file.

required

Returns:

Type Description
AppleHeicData

AppleHeicData dict containing:

AppleHeicData
  • base (np.ndarray): SDR image, uint8, shape (H, W, 3), Display P3.
AppleHeicData
  • gainmap (np.ndarray): Gain map, uint8, shape (H, W, 1), 1/4 resolution.
AppleHeicData
  • headroom (float): Peak luminance headroom, typically 2.0-8.0.

Raises:

Type Description
ValueError

If base image, gainmap, or headroom cannot be extracted.

Note

Requires exiftool to be installed and accessible in PATH for headroom extraction from EXIF/MakerNotes metadata.

See Also
  • apple_heic_to_hdr: Convert AppleHeicData to linear HDR.
  • has_gain_map: Check if HEIC file contains gain map.
Source code in src/hdrconv/io/apple_heic.py
def read_apple_heic(filepath: str) -> AppleHeicData:
    """Read Apple HEIC HDR file with gain map.

        Extracts the base SDR image, HDR gain map, and headroom metadata from
        iPhone HEIC photos containing Apple's proprietary HDR format.

        Args:
            filepath: Path to the Apple HEIC file.

        Returns:
            AppleHeicData dict containing:
            - ``base`` (np.ndarray): SDR image, uint8, shape (H, W, 3), Display P3.
            - ``gainmap`` (np.ndarray): Gain map, uint8, shape (H, W, 1), 1/4 resolution.
            - ``headroom`` (float): Peak luminance headroom, typically 2.0-8.0.

        Raises:
            ValueError: If base image, gainmap, or headroom cannot be extracted.

        Note:
            Requires exiftool to be installed and accessible in PATH for
            headroom extraction from EXIF/MakerNotes metadata.

        See Also:
            - `apple_heic_to_hdr`: Convert AppleHeicData to linear HDR.
            - `has_gain_map`: Check if HEIC file contains gain map.
    """

    base, gainmap = read_base_and_gain_map(filepath)
    headroom = get_headroom(filepath)

    if base is None or gainmap is None or headroom is None:
        raise ValueError(f"Failed to read Apple HEIC data from {filepath}")

    return AppleHeicData(base=base, gainmap=gainmap, headroom=headroom)

read_ios_hdr_screenshot(filepath, grid_cols=None, grid_rows=None, tile_size=512, real_width=None, real_height=None)

Read iOS HDR screenshot HEIC file.

Extracts the main image, gainmap, and metadata from iOS HDR screenshots and returns a standard GainmapImage structure suitable for use with gainmap_to_hdr.

Parameters:

Name Type Description Default
filepath str

Path to the iOS HDR screenshot HEIC file.

required
grid_cols Optional[int]

Number of tile columns (auto-detected if None).

None
grid_rows Optional[int]

Number of tile rows (auto-detected if None).

None
tile_size int

Size of each square tile in pixels. Default: 512.

512
real_width Optional[int]

Actual image width (auto-detected if None).

None
real_height Optional[int]

Actual image height (auto-detected if None).

None

Returns:

Type Description
GainmapImage

GainmapImage dict containing:

GainmapImage
  • baseline (np.ndarray): Main image, uint8, shape (H, W, 3), Display P3.
GainmapImage
  • gainmap (np.ndarray): Gain map, uint8, shape (H, W, 3), three-channel.
GainmapImage
  • metadata (GainmapMetadata): Contains gainmap_max, offset values.
GainmapImage
  • baseline_icc (bytes | None): None.
GainmapImage
  • gainmap_icc (bytes | None): None.

Raises:

Type Description
RuntimeError

If external tools (MP4Box, ffmpeg) are not available.

ValueError

If the file cannot be parsed or is not a valid iOS HDR screenshot.

FileNotFoundError

If the input file does not exist.

Note

Requires MP4Box (from GPAC) and ffmpeg to be installed and available in PATH.

The gainmap_min is always 0 and gainmap_gamma is always 1 for iOS HDR screenshots. Both baseline_offset and alternate_offset are set to the same value extracted from the tmap metadata.

See Also
  • gainmap_to_hdr: Convert the returned GainmapImage to linear HDR.
Source code in src/hdrconv/io/ios_hdr_screenshot.py
def read_ios_hdr_screenshot(
    filepath: str,
    grid_cols: Optional[int] = None,
    grid_rows: Optional[int] = None,
    tile_size: int = 512,
    real_width: Optional[int] = None,
    real_height: Optional[int] = None,
) -> GainmapImage:
    """Read iOS HDR screenshot HEIC file.

    Extracts the main image, gainmap, and metadata from iOS HDR screenshots
    and returns a standard GainmapImage structure suitable for use with
    `gainmap_to_hdr`.

    Args:
        filepath: Path to the iOS HDR screenshot HEIC file.
        grid_cols: Number of tile columns (auto-detected if None).
        grid_rows: Number of tile rows (auto-detected if None).
        tile_size: Size of each square tile in pixels. Default: 512.
        real_width: Actual image width (auto-detected if None).
        real_height: Actual image height (auto-detected if None).

    Returns:
        GainmapImage dict containing:
        - ``baseline`` (np.ndarray): Main image, uint8, shape (H, W, 3), Display P3.
        - ``gainmap`` (np.ndarray): Gain map, uint8, shape (H, W, 3), three-channel.
        - ``metadata`` (GainmapMetadata): Contains gainmap_max, offset values.
        - ``baseline_icc`` (bytes | None): None.
        - ``gainmap_icc`` (bytes | None): None.

    Raises:
        RuntimeError: If external tools (MP4Box, ffmpeg) are not available.
        ValueError: If the file cannot be parsed or is not a valid iOS HDR screenshot.
        FileNotFoundError: If the input file does not exist.

    Note:
        Requires MP4Box (from GPAC) and ffmpeg to be installed and available in PATH.

        The gainmap_min is always 0 and gainmap_gamma is always 1 for iOS HDR screenshots.
        Both baseline_offset and alternate_offset are set to the same value extracted
        from the tmap metadata.

    See Also:
        - `gainmap_to_hdr`: Convert the returned GainmapImage to linear HDR.
    """
    # Check dependencies
    available, missing = _check_dependencies()
    if not available:
        raise RuntimeError(
            f"Missing required external tools: {', '.join(missing)}. "
            "Please install them and ensure they are in PATH."
        )

    if not os.path.exists(filepath):
        raise FileNotFoundError(f"File not found: {filepath}")

    # Create temp directory for processing
    temp_dir = tempfile.mkdtemp(prefix="ios_hdr_")

    try:
        # Get all hvc1 IDs
        all_ids = _get_hvc1_ids(filepath)
        if not all_ids:
            raise ValueError("No hvc1 streams found in file")

        # Split into groups (main image and gainmap)
        groups = _split_ids_into_groups(all_ids)
        if len(groups) < 2:
            raise ValueError(
                "Expected at least 2 image groups (main + gainmap), "
                f"found {len(groups)}"
            )

        main_ids = groups[0]
        gainmap_ids = groups[1]

        # Auto-detect grid parameters if not provided
        if grid_cols is None or grid_rows is None:
            # Extract first tile to detect size
            first_id = main_ids[0]
            raw_path = os.path.join(temp_dir, f"{first_id}.hvc")
            jpg_path = os.path.join(temp_dir, "first_tile.jpg")

            param = f"{first_id}:path={raw_path}"
            subprocess.run(
                ["MP4Box", "-dump-item", param, filepath],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            subprocess.run(
                ["ffmpeg", "-y", "-i", raw_path, "-q:v", "2", jpg_path],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )

            detected_cols, detected_rows, detected_tile_size = _detect_grid_parameters(
                len(main_ids), jpg_path
            )
            grid_cols = grid_cols or detected_cols
            grid_rows = grid_rows or detected_rows
            tile_size = detected_tile_size

            # Clean up detection files
            if os.path.exists(raw_path):
                os.remove(raw_path)
            if os.path.exists(jpg_path):
                os.remove(jpg_path)

        # Get original resolution from HEIC metadata if not provided
        if real_width is None or real_height is None:
            orig_width, orig_height = _get_original_resolution(filepath)
            real_width = real_width or orig_width
            real_height = real_height or orig_height

        # Process main image
        main_temp = os.path.join(temp_dir, "main")
        os.makedirs(main_temp, exist_ok=True)
        main_image = _process_tile_group(
            main_ids, filepath, main_temp,
            grid_cols, grid_rows, tile_size,
            real_width or canvas_w, real_height or canvas_h,
        )

        # Process gainmap
        gainmap_temp = os.path.join(temp_dir, "gainmap")
        os.makedirs(gainmap_temp, exist_ok=True)
        gainmap_image = _process_tile_group(
            gainmap_ids, filepath, gainmap_temp,
            grid_cols, grid_rows, tile_size,
            real_width or canvas_w, real_height or canvas_h,
        )

        # Parse tmap metadata
        tmap_data = _dump_tmap_bytes(filepath, temp_dir)
        if tmap_data is None:
            raise ValueError("No tmap metadata found in file")

        gainmapmax, offset = _parse_gainmapmax_offset_from_tmap(tmap_data)

        # Construct GainmapMetadata
        # iOS HDR screenshots use: gainmap_min=0, gamma=1, both offsets equal
        metadata = GainmapMetadata(
            minimum_version=0,
            writer_version=0,
            baseline_hdr_headroom=1.0,
            alternate_hdr_headroom=float(2 ** gainmapmax),
            is_multichannel=True,
            use_base_colour_space=True,
            gainmap_min=(0.0, 0.0, 0.0),
            gainmap_max=(gainmapmax, gainmapmax, gainmapmax),
            gainmap_gamma=(1.0, 1.0, 1.0),
            baseline_offset=(offset, offset, offset),
            alternate_offset=(offset, offset, offset),
        )

        # Ensure arrays are uint8
        if main_image.dtype != np.uint8:
            main_image = main_image.astype(np.uint8)
        if gainmap_image.dtype != np.uint8:
            gainmap_image = gainmap_image.astype(np.uint8)

        return GainmapImage(
            baseline=main_image,
            gainmap=gainmap_image,
            metadata=metadata,
            baseline_icc=None,
            gainmap_icc=None,
        )

    finally:
        # Clean up temp directory
        shutil.rmtree(temp_dir, ignore_errors=True)

read_ultrahdr(filepath)

Read UltraHDR JPEG file.

Parameters:

Name Type Description Default
filepath str

Path to the UltraHDR JPEG file.

required

Returns:

Type Description
GainmapImage

GainmapImage dict containing baseline, gainmap, metadata, and ICC data.

Raises:

Type Description
ValueError

If gainmap stream or HDR gainmap metadata is missing.

Source code in src/hdrconv/io/ultrahdr.py
def read_ultrahdr(filepath: str) -> GainmapImage:
    """Read UltraHDR JPEG file.

    Args:
        filepath: Path to the UltraHDR JPEG file.

    Returns:
        GainmapImage dict containing baseline, gainmap, metadata, and ICC data.

    Raises:
        ValueError: If gainmap stream or HDR gainmap metadata is missing.
    """
    with open(filepath, "rb") as f:
        raw_data = f.read()

    primary_data, gainmap_data = _split_mpf_container(raw_data)

    # Fallback: split by EOI+SOI if MPF is missing
    if not gainmap_data:
        separator = b"\xff\xd9\xff\xd8"
        split_pos = raw_data.find(separator)
        if split_pos != -1:
            primary_data = raw_data[: split_pos + 2]
            gainmap_data = raw_data[split_pos + 2 :]

    if not gainmap_data:
        raise ValueError("No gainmap found in container (MPF missing or invalid).")

    with warnings.catch_warnings():
        warnings.filterwarnings(
            "ignore",
            message="Image appears to be a malformed MPO file",
            category=UserWarning,
        )
        base_img = Image.open(io.BytesIO(primary_data)).convert("RGB")
        gain_img = Image.open(io.BytesIO(gainmap_data)).convert("RGB")

    base_arr = np.array(base_img)
    gain_arr = np.array(gain_img)

    base_segments = list(_yield_jpeg_segments(primary_data))
    gain_segments = list(_yield_jpeg_segments(gainmap_data))

    base_icc = _extract_icc(base_segments)
    gain_icc = _extract_icc(gain_segments)

    hdrgm_meta = None

    # Prefer gainmap stream
    for segments in [gain_segments, base_segments]:
        for code, payload in segments:
            if code == APP1:
                xmp_xml = _extract_xmp_payload(payload)
                if not xmp_xml:
                    continue
                parsed = _parse_hdrgm_metadata(xmp_xml)
                if parsed and ("GainMapMin" in parsed or "Version" in parsed):
                    hdrgm_meta = parsed
                    break
        if hdrgm_meta:
            break

    if not hdrgm_meta:
        raise ValueError("UltraHDR gainmap metadata (XMP) not found.")

    metadata = _hdrgm_to_gainmap_metadata(hdrgm_meta, gain_arr)

    return GainmapImage(
        baseline=base_arr,
        gainmap=gain_arr,
        metadata=metadata,
        baseline_icc=base_icc,
        gainmap_icc=gain_icc,
    )

write_ultrahdr(data, filepath)

Write UltraHDR JPEG file.

Parameters:

Name Type Description Default
data GainmapImage

GainmapImage dict containing baseline, gainmap, and metadata.

required
filepath str

Output path for the JPEG file.

required
Source code in src/hdrconv/io/ultrahdr.py
def write_ultrahdr(data: GainmapImage, filepath: str) -> None:
    """Write UltraHDR JPEG file.

    Args:
        data: GainmapImage dict containing baseline, gainmap, and metadata.
        filepath: Output path for the JPEG file.
    """
    try:
        gainmap_bytes_raw = _create_jpeg_bytes(data["gainmap"], data.get("gainmap_icc"))

        # Insert minimal MPF APP2 in gainmap stream for compatibility
        gainmap_mpf_segment = _build_app2_segment(_build_mpf_minimal_payload(2))

        xmp_payload = _build_hdrgm_xmp(data["metadata"])
        xmp_segment = _build_app1_segment(xmp_payload)

        gainmap_final = (
            gainmap_bytes_raw[:2]
            + gainmap_mpf_segment
            + xmp_segment
            + gainmap_bytes_raw[2:]
        )

        primary_bytes_raw = _create_jpeg_bytes(
            data["baseline"], data.get("baseline_icc")
        )

        gcontainer_payload = _build_gcontainer_xmp(len(gainmap_final))
        gcontainer_segment = _build_app1_segment(gcontainer_payload)

        mpf_payload_temp = _build_mpf_payload(
            primary_size=len(primary_bytes_raw),
            gainmap_size=len(gainmap_final),
            gainmap_offset=0,
        )
        mpf_segment_temp = _build_app2_segment(mpf_payload_temp)

        total_primary_len = (
            len(primary_bytes_raw) + len(gcontainer_segment) + len(mpf_segment_temp)
        )

        mpf_marker_offset = 2 + len(gcontainer_segment)
        mpf_base_file_offset = mpf_marker_offset + 8
        gainmap_relative_offset = total_primary_len - mpf_base_file_offset

        mpf_payload_final = _build_mpf_payload(
            primary_size=total_primary_len,
            gainmap_size=len(gainmap_final),
            gainmap_offset=gainmap_relative_offset,
        )
        mpf_segment_final = _build_app2_segment(mpf_payload_final)

        primary_final = (
            primary_bytes_raw[:2]
            + gcontainer_segment
            + mpf_segment_final
            + primary_bytes_raw[2:]
        )

        with open(filepath, "wb") as f:
            f.write(primary_final)
            f.write(gainmap_final)

    except Exception as e:
        raise RuntimeError(f"Failed to write UltraHDR file: {filepath}") from e