intermediateView Python source
Make thumbnails from a folder of images
This example creates 24 test images (PNG and JPEG, 320 to 1280 pixels wide). It writes a 64-pixel thumbnail for each image and returns a table of metadata. The image bytes stay inside the task that reads them.
Run it
Run all commands below from the repository root (sail-examples/):
uv run multimodal/image-thumbnails/main.py
Output
The newest capture appears first. Older output stays here when a result changes.
Current output · Sail 0.7.3
Captured on Sail 0.7.3. Latest capture: 2026-11-05, Python 3.12.14.
+------+------+-------------+---------------+----------------+
|format|images|source_pixels|max_thumb_width|max_thumb_height|
+------+------+-------------+---------------+----------------+
| JPEG| 12| 5374528| 64| 64|
| PNG| 12| 7142400| 64| 48|
+------+------+-------------+---------------+----------------+
+------------+-----+------+-----------+------------+
| name|width|height|thumb_width|thumb_height|
+------------+-----+------+-----------+------------+
|image-00.png| 320| 240| 64| 48|
|image-01.jpg| 640| 480| 64| 48|
|image-02.png| 800| 600| 64| 48|
|image-03.jpg| 1024| 768| 64| 48|
|image-04.png| 1280| 960| 64| 48|
+------------+-----+------+-----------+------------+
only showing top 5 rows
Python code
main.py143 lines
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "pysail==0.7.3",
# "pyspark-client==4.1.3",
# "pyarrow==26.0.0",
# "pandas==3.0.6",
# "pillow==13.0.0",
# ]
# ///
"""Make thumbnails for a folder of images: one row per file, small results out.
The pattern: list the files into a table of paths, open each file inside `mapInArrow`, write
the thumbnail, and return only metadata. The image bytes never travel between stages.
"""
import atexit
import random
import shutil
import tempfile
import zlib
from pathlib import Path
import pyarrow as pa
from pyspark.sql import DataFrame, SparkSession
from pyspark.sql import functions as F
THUMBNAIL_SIZE = 64
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff"}
SIZES = [(320, 240), (640, 480), (800, 600), (1024, 768), (1280, 960), (500, 500)]
# The declared schema and the Arrow schema of every returned batch must match exactly:
# Apache Spark rejects an int64 column declared as `int`.
COLUMNS = [
("name", "string", pa.string()),
("format", "string", pa.string()),
("width", "int", pa.int32()),
("height", "int", pa.int32()),
("thumb_width", "int", pa.int32()),
("thumb_height", "int", pa.int32()),
("mean_r", "int", pa.int32()),
("mean_g", "int", pa.int32()),
("mean_b", "int", pa.int32()),
("thumb_crc32", "long", pa.int64()),
]
SCHEMA = ", ".join(f"{name} {sql}" for name, sql, _ in COLUMNS)
ARROW_SCHEMA = pa.schema([(name, arrow) for name, _, arrow in COLUMNS])
def temp_dir(prefix: str) -> str:
"""A temporary directory that is removed when the process exits."""
path = tempfile.mkdtemp(prefix=prefix)
atexit.register(shutil.rmtree, path, ignore_errors=True)
return path
def make_images(directory: Path, count: int = 24) -> None:
"""Draw `count` deterministic test images, half PNG and half JPEG."""
from PIL import Image, ImageDraw
for i in range(count):
rng = random.Random(i)
width, height = SIZES[i % len(SIZES)]
image = Image.new("RGB", (width, height), tuple(rng.randrange(256) for _ in range(3)))
draw = ImageDraw.Draw(image)
for _ in range(8):
x0, y0 = rng.randrange(width), rng.randrange(height)
x1, y1 = min(width, x0 + rng.randrange(20, width)), min(height, y0 + rng.randrange(20, height))
color = tuple(rng.randrange(256) for _ in range(3))
(draw.ellipse if rng.random() < 0.5 else draw.rectangle)([x0, y0, x1, y1], fill=color)
if i % 2:
image.save(directory / f"image-{i:02d}.jpg", quality=90)
else:
image.save(directory / f"image-{i:02d}.png")
def make_thumbnailer(output_dir: str):
"""Build the mapInArrow function; it writes thumbnails and returns one metadata row per file."""
def thumbnails(batches):
from PIL import Image, ImageOps, ImageStat
for batch in batches:
rows = {name: [] for name in ARROW_SCHEMA.names}
for path in batch.column("path").to_pylist():
with Image.open(path) as image:
rows["format"].append(image.format)
rows["width"].append(image.width)
rows["height"].append(image.height)
# Apply the camera's orientation tag, so photos are not stored sideways.
thumb = ImageOps.exif_transpose(image).convert("RGB")
thumb.thumbnail((THUMBNAIL_SIZE, THUMBNAIL_SIZE))
# Keep the extension in the name: a.jpg and a.png must not overwrite each other.
thumb.save(Path(output_dir) / f"{Path(path).name}.png")
mean = [round(c) for c in ImageStat.Stat(thumb).mean]
rows["name"].append(Path(path).name)
rows["thumb_width"].append(thumb.width)
rows["thumb_height"].append(thumb.height)
rows["mean_r"].append(mean[0])
rows["mean_g"].append(mean[1])
rows["mean_b"].append(mean[2])
rows["thumb_crc32"].append(zlib.crc32(thumb.tobytes()))
yield pa.RecordBatch.from_pydict(rows, schema=ARROW_SCHEMA)
return thumbnails
def run(spark: SparkSession) -> dict[str, DataFrame]:
images = Path(temp_dir("images-"))
make_images(images)
output = temp_dir("thumbnails-")
# A table of paths, one row per file. Each task opens only the files in its own rows.
files = sorted(str(p) for p in images.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
paths = spark.createDataFrame([(p,) for p in files], "path string").repartition(4)
metadata = paths.mapInArrow(make_thumbnailer(output), SCHEMA)
# Save the metadata once: rereading it below must not decode the images again.
metadata_path = temp_dir("metadata-")
metadata.write.mode("overwrite").parquet(metadata_path)
saved = spark.read.parquet(metadata_path)
summary = saved.groupBy("format").agg(
F.count("*").alias("images"),
F.sum(F.col("width") * F.col("height")).alias("source_pixels"),
F.max("thumb_width").alias("max_thumb_width"),
F.max("thumb_height").alias("max_thumb_height"),
)
return {"thumbnails": saved.orderBy("name"), "summary": summary.orderBy("format")}
if __name__ == "__main__":
from pysail.spark import SparkConnectServer
server = SparkConnectServer()
server.start()
_, port = server.listening_address
spark = SparkSession.builder.remote(f"sc://localhost:{port}").getOrCreate()
results = run(spark)
results["summary"].show()
results["thumbnails"].select("name", "width", "height", "thumb_width", "thumb_height").show(5)
spark.stop()
server.stop()
How it works
- A table of paths. The file list becomes a DataFrame with one
pathrow per file, split into four partitions. Each task opens only the files in its own rows. - Decode and resize in
mapInArrow. Pillow opens each file, records its format and size, writes a thumbnail, and computes the thumbnail's mean color and checksum. - Small results out. The function returns a few numbers per image, never the image bytes. Large binary values never flow between stages, so batches stay small.
- Save once. The metadata is written to Parquet before it is aggregated, so later queries never decode the images again.
The same pattern covers PDFs, audio and video: read the file in the UDF, return text, features or embeddings.
Use your own files
Replace make_images and the files list with your own paths. They must be readable from wherever the UDF runs: local paths on one machine, object storage URLs (read with a client such as fsspec) on a cluster.