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
DefaultPackagerand set the packable typeImplement
pack_file()to save an image as a file artifactImplement
pack_plot()to create an inline-preview artifactClean 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.Imagetells the packager manager that this packager handles PIL ImagesPACK_SUBCLASSES = Trueso subclasses (e.g.JpegImageFile) are also handledDEFAULT_PACKING_ARTIFACT_TYPE = ArtifactType.PLOTmeans that if the user doesn't specify an artifact type, the image is packed as an inline previewpack_file()accepts aformatkwarg — users can override it via log hints like"image : file[format=jpeg]". The temp directory is registered for cleanuppack_plot()converts the image to PNG bytes and wraps them in aPlotArtifactfor inline display in the MLRun UIThis is a true pack-only packager — no
unpack_*methods are neededDefaultPackagerdiscovers 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 |
|
Set type |
|
Pack as file |
|
Pack as plot |
|
Temp cleanup |
|
Packing kwarg |
|
Register |
|
Next: See the LangChain packager tutorial for a round-trip packager that also implements unpacking.