Creating a custom packager - pack-only tutorial#

This tutorial walks through creating a pack-only custom packager for PIL.Image.Image. Pack-only means the packager handles output serialization — images are produced by functions but not consumed as typed inputs.

You will learn how to:

  • Subclass DefaultPackager and set the packable type

  • Implement pack_file() to save an image as a file artifact

  • Implement pack_plot() to create an inline-preview artifact

  • Clean up temporary files with add_future_clearing_path()

  • Add packing kwargs so users can choose the image format

  • Register and test the packager

In this section

The problem#

Without a custom packager, returning a PIL.Image.Image from an MLRun function causes the default packager to pickle it. The resulting .pkl file is:

  • Opaque — you can't view the image in the MLRun UI or a file browser

  • Fragile — pickle files are tied to the exact Pillow version

Ideally, images are saved as standard PNG (or JPEG) files that anyone can view.

Setup#

Create project#

import mlrun
project = mlrun.get_or_create_project("pack-only-tutorial", "./")

Write a function#

This handler generates a simple gradient image using Pillow — no external data or API keys required.

%%writefile generate_image.py

from PIL import Image


def generate_gradient(width: int = 256, height: int = 256) -> Image.Image:
    """
    Generate a simple RGB gradient image.

    :param width:  Image width in pixels.
    :param height: Image height in pixels.

    :returns: A PIL Image with a red-green gradient.
    """
    img = Image.new("RGB", (width, height))
    for x in range(width):
        for y in range(height):
            r = int(255 * x / width)
            g = int(255 * y / height)
            img.putpixel((x, y), (r, g, 50))
    return img
fn = project.set_function(
    "generate_image.py",
    name="gen-image",
    kind="job",
    image="mlrun/mlrun",
    handler="generate_gradient",
)

Create the custom packager#

Write the packager#

The packager is written to a .py file so it can be imported by MLRun at runtime. This code walks you through each piece.

%%writefile image_packager.py

import os
import tempfile
from io import BytesIO

from PIL import Image

from mlrun.artifacts import Artifact
from mlrun.artifacts.plots import PlotArtifact
from mlrun.package.packagers.default_packager import DefaultPackager
from mlrun.package.utils import ArtifactType


class PILImagePackager(DefaultPackager):
    """
    A custom packager for PIL Image objects.

    Supports two artifact types:
    - ``file``  — saves the image to disk in the requested format (default PNG).
    - ``plot``  — embeds the image as a base64 inline preview (PlotArtifact).
    """

    PACKABLE_OBJECT_TYPE = Image.Image
    PACK_SUBCLASSES = True  # also handle Image subclasses
    DEFAULT_PACKING_ARTIFACT_TYPE = ArtifactType.PLOT

    def pack_file(
        self,
        obj: Image.Image,
        key: str,
        format: str = "png",
    ) -> tuple[Artifact, dict]:
        """
        Save the image to a temporary file and return a file Artifact.

        :param obj:    The PIL Image to pack.
        :param key:    The artifact key.
        :param format: Image format — ``png``, ``jpeg``, ``bmp``, etc.

        :returns: The artifact and unpacking instructions.
        """
        # Create a temp directory and save the image
        temp_dir = tempfile.mkdtemp()
        file_path = os.path.join(temp_dir, f"{key}.{format}")
        obj.save(file_path, format=format.upper())

        # Mark for cleanup after upload
        self.add_future_clearing_path(temp_dir)

        artifact = Artifact(key=key, src_path=file_path)
        instructions = {"format": format}
        return artifact, instructions

    def pack_plot(
        self,
        obj: Image.Image,
        key: str,
    ) -> tuple[PlotArtifact, dict]:
        """
        Create a PlotArtifact with the image embedded as PNG bytes for
        inline preview in the MLRun UI.

        :param obj: The PIL Image to pack.
        :param key: The artifact key.

        :returns: The plot artifact and unpacking instructions.
        """
        buf = BytesIO()
        obj.save(buf, format="PNG")
        png_bytes = buf.getvalue()

        artifact = PlotArtifact(key=key, body=png_bytes, title=key)
        return artifact, {}

Notice:

  • PACKABLE_OBJECT_TYPE = Image.Image tells the packager manager that this packager handles PIL Images

  • PACK_SUBCLASSES = True so subclasses (e.g. JpegImageFile) are also handled

  • DEFAULT_PACKING_ARTIFACT_TYPE = ArtifactType.PLOT means that if the user doesn't specify an artifact type, the image is packed as an inline preview

  • pack_file() accepts a format kwarg — users can override it via log hints like "image : file[format=jpeg]". The temp directory is registered for cleanup

  • pack_plot() converts the image to PNG bytes and wraps them in a PlotArtifact for inline display in the MLRun UI

  • This is a true pack-only packager — no unpack_* methods are needed DefaultPackager discovers packing and unpacking artifact types independently, so "file" and "plot" are automatically available for packing only

Register the packager#

Add the packager to the project's custom packagers list. The is_mandatory=True flag means it is mandatory — if the import fails, the run fails.

project.add_custom_packager(
    packager="image_packager.PILImagePackager", is_mandatory=True
)

Test 1: pack as plot (default)#

Since DEFAULT_PACKING_ARTIFACT_TYPE is "plot", a simple key-only log hint produces an inline preview artifact.

run_plot = fn.run(
    local=True,
    params={"width": 128, "height": 128},
    returns=["gradient"],  # no artifact type → uses default (plot)
)
run_plot.outputs

Test 2: pack as file with format kwarg#

Use the log hint "gradient : file[format=jpeg]" to save as JPEG instead of PNG.

run_file = fn.run(
    local=True,
    params={"width": 128, "height": 128},
    returns=["gradient : file[format=jpeg]"],
)
run_file.outputs

Recap#

In this tutorial you built a pack-only custom packager for PIL Images:

Step

What you did

Subclass

class PILImagePackager(DefaultPackager)

Set type

PACKABLE_OBJECT_TYPE = Image.Image

Pack as file

pack_file() — saves to disk in the requested format

Pack as plot

pack_plot() — embeds PNG bytes for inline preview

Temp cleanup

self.add_future_clearing_path(temp_dir)

Packing kwarg

format parameter — controllable via "key : file[format=jpeg]"

Register

project.add_custom_packager(packager="module.Class", is_mandatory=True)

Next: See the LangChain packager tutorial for a round-trip packager that also implements unpacking.