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

JPEG building blocks (encode a plain JPEG and embed ICC/APPn segments without PIL): encode_jpeg, build_icc_segments, insert_segments.

build_icc_segments(icc)

Split an ICC profile into chunked APP2 segments (ICC.1 Annex B).

Returns an empty list when icc is None or empty, so results can be spliced unconditionally into a segment list.

Source code in src/hdrconv/io/_jpeg.py
def build_icc_segments(icc: Optional[bytes]) -> List[bytes]:
    """Split an ICC profile into chunked APP2 segments (ICC.1 Annex B).

    Returns an empty list when `icc` is None or empty, so results can be
    spliced unconditionally into a segment list.
    """
    if not icc:
        return []

    chunks = [icc[i : i + _MAX_ICC_CHUNK] for i in range(0, len(icc), _MAX_ICC_CHUNK)]
    if len(chunks) > 255:
        raise ValueError(f"ICC profile too large to embed: {len(icc)} bytes")

    total = len(chunks)
    return [
        build_segment(APP2, ICC_PROFILE_LABEL + bytes((seq, total)) + chunk)
        for seq, chunk in enumerate(chunks, start=1)
    ]

encode_jpeg(img_arr, quality=95)

Encode a numpy array as baseline JPEG with 4:4:4 chroma subsampling.

Accepts uint8, float ([0, 1] range) or deeper integer arrays (rescaled from the dtype's full range — use normalize_to_uint8 first when the true bit depth is known), shaped (H, W), (H, W, 1), (H, W, 3) or (H, W, 4); single-channel input becomes grayscale JPEG and an alpha channel is dropped. No metadata segments are embedded — callers add ICC/MPF/XMP segments via insert_segments.

Source code in src/hdrconv/io/_jpeg.py
def encode_jpeg(img_arr: np.ndarray, quality: int = 95) -> bytes:
    """Encode a numpy array as baseline JPEG with 4:4:4 chroma subsampling.

    Accepts uint8, float ([0, 1] range) or deeper integer arrays (rescaled
    from the dtype's full range — use `normalize_to_uint8` first when the
    true bit depth is known), shaped (H, W), (H, W, 1), (H, W, 3) or
    (H, W, 4); single-channel input becomes grayscale JPEG and an alpha
    channel is dropped. No metadata segments are embedded — callers add
    ICC/MPF/XMP segments via `insert_segments`.
    """
    img_arr = normalize_to_uint8(img_arr)

    if img_arr.ndim == 3:
        if img_arr.shape[2] == 1:
            img_arr = img_arr[:, :, 0]
        elif img_arr.shape[2] == 4:
            img_arr = img_arr[:, :, :3]

    # jpeg_encode rejects non-contiguous input (e.g. the alpha-drop slice
    # above, or transposed/flipped caller arrays).
    img_arr = np.ascontiguousarray(img_arr)

    return jpeg_encode(img_arr, level=quality, subsampling="444")

insert_segments(jpeg, segments)

Insert prebuilt marker segments into a JPEG stream's header area.

Source code in src/hdrconv/io/_jpeg.py
def insert_segments(jpeg: bytes, segments: Sequence[bytes]) -> bytes:
    """Insert prebuilt marker segments into a JPEG stream's header area."""
    pos = _header_insert_pos(jpeg)
    return b"".join([jpeg[:pos], *segments, jpeg[pos:]])

read_21496(filepath)

Read an ISO 21496-1 Gainmap image.

Routes JPEG files to the MPF parser and HEIF/AVIF files to the ISOBMFF parser.

Source code in src/hdrconv/io/iso21496.py
def read_21496(filepath: str) -> GainmapImage:
    """Read an ISO 21496-1 Gainmap image.

    Routes JPEG files to the MPF parser and HEIF/AVIF files to the ISOBMFF
    parser.
    """
    container = _detect_21496_container(filepath)
    if container == "jpeg":
        return _read_21496_jpeg(filepath)
    if container == "isobmff":
        return _read_21496_isobmff(filepath)

    raise ValueError(f"Unsupported ISO 21496-1 container format: {filepath}")

write_21496(data, filepath, baseline_quality=95, gainmap_quality=95)

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
baseline_quality int

JPEG quality for baseline image (1-100, default 95).

95
gainmap_quality int

JPEG quality for gainmap image (1-100, default 95).

95

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,
    baseline_quality: int = 95,
    gainmap_quality: int = 95,
) -> 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.
        baseline_quality: JPEG quality for baseline image (1-100, default 95).
        gainmap_quality: JPEG quality for gainmap image (1-100, default 95).

    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:
        # Gainmap stream: minimal MPF, ISO 21496-1 metadata, then ICC chunks.
        # Integer inputs deeper than 8 bits (e.g. from the ISOBMFF/screenshot
        # readers) are rescaled to uint8 using the recorded bit depth.
        gainmap_stream = insert_segments(
            encode_jpeg(
                normalize_to_uint8(data["gainmap"], data.get("gainmap_bit_depth")),
                gainmap_quality,
            ),
            [
                build_segment(APP2, build_mpf_minimal_payload(2)),
                build_segment(APP2, _encode_iso21496_metadata(data["metadata"])),
                *build_icc_segments(data.get("gainmap_icc")),
            ],
        )

        # Primary stream: URN stub, MPF index pointing at the gainmap, ICC.
        urn_stub = build_segment(APP2, ISO21496_URN + b"\x00\x00\x00\x00")
        file_bytes = assemble_mpf_file(
            primary_jpeg=encode_jpeg(
                normalize_to_uint8(data["baseline"], data.get("baseline_bit_depth")),
                baseline_quality,
            ),
            gainmap_stream=gainmap_stream,
            segments_before_mpf=[urn_stub],
            segments_after_mpf=build_icc_segments(data.get("baseline_icc")),
        )

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

    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 parsed from the file's nclx colr box ('bt709', 'p3', or 'bt2020'; defaults to 'bt2020').
HDRImage
  • transfer_function (str): Always 'pq'.
HDRImage
  • icc_profile (bytes | None): Currently None (not extracted).
Note

Sample values are normalized using the actual coded bit depth (8, 10, or 12), parsed from the file's av1C box. If the depth cannot be determined for 16-bit samples, 10-bit is assumed.

See Also
  • write_22028_pq: Write HDR image to PQ AVIF format.
  • colour.eotf(data, 'ITU-R BT.2100 PQ'): Convert PQ-encoded data to linear light (see examples/pq_to_gainmap.py).
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 parsed from the file's nclx
            colr box ('bt709', 'p3', or 'bt2020'; defaults to 'bt2020').
        - ``transfer_function`` (str): Always 'pq'.
        - ``icc_profile`` (bytes | None): Currently None (not extracted).

    Note:
        Sample values are normalized using the actual coded bit depth
        (8, 10, or 12), parsed from the file's av1C box. If the depth
        cannot be determined for 16-bit samples, 10-bit is assumed.

    See Also:
        - `write_22028_pq`: Write HDR image to PQ AVIF format.
        - ``colour.eotf(data, 'ITU-R BT.2100 PQ')``: Convert PQ-encoded
            data to linear light (see ``examples/pq_to_gainmap.py``).
    """
    with open(filepath, "rb") as f:
        avif_bytes = f.read()
    image_array = avif_decode(avif_bytes, numthreads=-1)
    # Normalize samples to [0, 1] using the actual coded bit depth.
    if image_array.dtype == np.uint8:
        bit_depth = 8
    else:
        bit_depth = _parse_avif_bit_depth(avif_bytes)
        if bit_depth not in (10, 12):
            # Fall back to the most common HDR AVIF depth.
            bit_depth = 10
    image_array = (image_array / float((1 << bit_depth) - 1)).astype(np.float32)

    color_space_map = {1: "bt709", 9: "bt2020", 12: "p3"}
    color_space = color_space_map.get(_parse_avif_primaries(avif_bytes), "bt2020")
    return HDRImage(
        data=image_array,
        color_space=color_space,
        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]. - transfer_function: Transfer function ('pq', 'hlg', etc.). May contain: - color_space: Color primaries ('bt709', 'p3', 'bt2020'). Defaults to 'bt2020'.

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.
  • colour.eotf_inverse(data, 'ITU-R BT.2100 PQ'): Convert linear HDR to PQ-encoded values (see examples/gainmap_to_pq.py).
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].
            - ``transfer_function``: Transfer function ('pq', 'hlg', etc.).
            May contain:
            - ``color_space``: Color primaries ('bt709', 'p3', 'bt2020').
                Defaults to 'bt2020'.
        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.
        - ``colour.eotf_inverse(data, 'ITU-R BT.2100 PQ')``: Convert linear
            HDR to PQ-encoded values (see ``examples/gainmap_to_pq.py``).
    """
    # 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.get("color_space", "bt2020"), 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.round(np_array * 1023.0)
    np_array = np_array.astype(np.uint16)

    encode_kwargs = dict(
        level=90,
        speed=8,
        bitspersample=10,
        primaries=primaries_code,
        transfer=transfer_code,
        numthreads=-1,
    )
    if _AVIF_ENCODE_SUPPORTS_MATRIX:
        # BT.2020 non-constant luminance (CICP matrix 9), per ISO 22028-5.
        encode_kwargs["matrix"] = 9

    avif_bytes: bytes = avif_encode(np_array, **encode_kwargs)

    # 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, icc_profile = _read_base_gain_map_and_icc(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, icc_profile=icc_profile
    )

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, uint16, shape (H, W, 3), Display P3.
GainmapImage
  • gainmap (np.ndarray): Gain map, uint16, 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, uint16, shape (H, W, 3), Display P3.
        - ``gainmap`` (np.ndarray): Gain map, uint16, 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:
            group_sizes = [len(g) for g in groups]
            raise ValueError(
                "Expected at least 2 image groups (main + gainmap), "
                f"found {len(groups)} group(s) with sizes {group_sizes} "
                f"from hvc1 item IDs {all_ids}"
            )

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

        # 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

        # Auto-detect grid parameters after original resolution is known.
        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")
            png_path = os.path.join(temp_dir, "first_tile.png")

            param = f"{first_id}:path={raw_path}"
            subprocess.run(
                ["MP4Box", "-dump-item", param, filepath],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            _decode_tile_to_png16(raw_path, png_path)

            detected_cols, detected_rows, detected_tile_size = _detect_grid_parameters(
                len(main_ids),
                png_path,
                real_width=real_width,
                real_height=real_height,
            )
            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(png_path):
                os.remove(png_path)

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

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

        # 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=0.0,
            alternate_hdr_headroom=float(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),
        )

        # ffmpeg upscales the 10-bit HEVC samples into the full 16-bit PNG
        # container, so the arrays are true 16-bit uint16 data.
        return GainmapImage(
            baseline=main_image,
            gainmap=gainmap_image,
            metadata=metadata,
            baseline_icc=None,
            gainmap_icc=None,
            baseline_bit_depth=16,
            gainmap_bit_depth=16,
        )

    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 at the primary stream's true EOI if MPF is missing
    if not gainmap_data:
        eoi_end = _find_jpeg_eoi(raw_data)
        if eoi_end != -1 and raw_data[eoi_end : eoi_end + 2] == SOI:
            primary_data = raw_data[:eoi_end]
            gainmap_data = raw_data[eoi_end:]

    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))
        if gain_img.mode not in ("L", "RGB"):
            gain_img = gain_img.convert("RGB")

    base_arr = np.array(base_img)
    gain_arr = np.array(gain_img)
    if gain_arr.ndim == 2:
        gain_arr = gain_arr[:, :, np.newaxis]
    elif gain_arr.ndim == 3 and gain_arr.shape[2] == 4:
        gain_arr = gain_arr[:, :, :3]

    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 any(k in parsed for k in _HDRGM_DATA_FIELDS):
                    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,
        baseline_bit_depth=8,
        gainmap_bit_depth=8,
    )

write_ultrahdr(data, filepath, baseline_quality=95, gainmap_quality=95)

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
baseline_quality int

JPEG quality for baseline image (1-100, default 95).

95
gainmap_quality int

JPEG quality for gainmap image (1-100, default 95).

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

    Args:
        data: GainmapImage dict containing baseline, gainmap, and metadata.
        filepath: Output path for the JPEG file.
        baseline_quality: JPEG quality for baseline image (1-100, default 95).
        gainmap_quality: JPEG quality for gainmap image (1-100, default 95).
    """
    try:
        # Gainmap stream: minimal MPF, HDR gain map XMP, then ICC chunks.
        # Integer inputs deeper than 8 bits (e.g. from the ISOBMFF/screenshot
        # readers) are rescaled to uint8 using the recorded bit depth.
        gainmap_stream = insert_segments(
            encode_jpeg(
                normalize_to_uint8(data["gainmap"], data.get("gainmap_bit_depth")),
                gainmap_quality,
            ),
            [
                build_segment(APP2, build_mpf_minimal_payload(2)),
                build_segment(APP1, _build_hdrgm_xmp(data["metadata"])),
                *build_icc_segments(data.get("gainmap_icc")),
            ],
        )

        # Primary stream: GContainer XMP, MPF index to the gainmap, ICC.
        gcontainer_segment = build_segment(
            APP1, _build_gcontainer_xmp(len(gainmap_stream))
        )
        file_bytes = assemble_mpf_file(
            primary_jpeg=encode_jpeg(
                normalize_to_uint8(data["baseline"], data.get("baseline_bit_depth")),
                baseline_quality,
            ),
            gainmap_stream=gainmap_stream,
            segments_before_mpf=[gcontainer_segment],
            segments_after_mpf=build_icc_segments(data.get("baseline_icc")),
        )

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

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