Stop choosing campaign artwork by opening files one at a time. Build a labeled contact sheet, compare the candidates together, and keep a manifest that maps every review ID to the exact source file.

This guide supplies a local Python tool for the review stage of an AI creative workflow. It keeps full compositions visible rather than cropping every candidate into the same shape. It produces a PNG sheet and returns IDs, filenames, input hashes and dimensions. It does not rank images or choose a winner.

A review surface, not another generation

Neon Orchard is a fictional business preparing six campaign image candidates. A reviewer needs to compare composition and available space for copy, then refer to C02 or C05 without confusing similar filenames. The worked example uses six authored geometric test pictures, not AI outputs, customer artwork or a completed campaign. The same tool can read trusted still PNG/JPEG candidates exported from a creative application.

Use a separate input directory containing only the candidates for this review. The script reads the immediate directory, sorts supported filenames in Python’s case-sensitive string order and assigns C01 onward. These IDs are stable for that exact sorted set; adding or renaming a file can shift them. Keep the manifest and sheet together, and identify a selected asset by its filename and SHA-256 as well as its short review ID.

Keep the frame instead of hiding the edges

The Pillow ImageOps reference documents aspect-preserving containment and EXIF orientation handling. The tool applies orientation, composites transparency onto white and fits the entire image inside a 280 by 180 pixel area. The 12.2.0 source implementation was inspected alongside the executed fixture.

Review ID Authored source dimensions Actual contained thumbnail Cell position
C01 600 × 300 280 × 140 Row 1, column 1
C02 300 × 600 90 × 180 Row 1, column 2
C03 400 × 400 180 × 180 Row 1, column 3
C04 900 × 300 280 × 93 Row 2, column 1
C05 300 × 900 60 × 180 Row 2, column 2
C06 500 × 400 225 × 180 Row 2, column 3
Layout map of the executed synthetic contact sheet. Each cell is 300 × 230; three columns and two rows produce a 900 × 460 PNG. Blank space is intentional: all candidate edges remain visible.

The tradeoff is deliberate: a tall candidate appears smaller than a landscape one. Equal thumbnail area and an uncropped whole frame are different review goals. Use this sheet to shortlist compositions, then inspect originals at full resolution. Small text, edge defects, color accuracy and suitability for a particular placement need their own review.

Run it in a disposable workspace

Execution used Windows, Python 3.14.3 and the already installed Pillow 12.2.0. The current stable documentation identifies itself as 13.0.0; this article does not claim to have tested that newer release. To reproduce the tested package version in a new virtual environment, install Pillow==12.2.0 using that environment’s Python package manager. The run here used the existing installation, with no paid service or model call. Pillow’s MIT-CMU license includes notice conditions; it does not grant rights to your input artwork.

Save the following three files in a new directory. First, contact_sheet.py:

"""Bounded, local contact sheet for authored or trusted campaign PNG/JPEG files."""
from pathlib import Path
import hashlib
import json
import sys
import warnings
from PIL import Image, ImageDraw, ImageOps


def build_sheet(folder, output):
    folder, output = Path(folder), Path(output)
    if not folder.is_dir():
        raise ValueError('Input must be a directory')
    files = sorted((p for p in folder.iterdir()
                    if p.suffix.lower() in {'.png', '.jpg', '.jpeg'}),
                   key=lambda p: p.name)
    if not 1 <= len(files) <= 12:
        raise ValueError('Choose 1 to 12 PNG/JPEG files')
    if output.exists() or output.resolve() in [p.resolve() for p in files]:
        raise ValueError('Output must be a new file')
    if output.suffix.lower() != '.png':
        raise ValueError('Output must end in .png')
    records, thumbnails = [], []
    for index, path in enumerate(files, 1):
        if path.is_symlink() or not path.is_file():
            raise ValueError('Input must be a regular non-symlink file')
        if path.stat().st_size > 10_000_000:
            raise ValueError('Input exceeds 10 MB')
        digest = hashlib.sha256(path.read_bytes()).hexdigest()
        with warnings.catch_warnings():
            warnings.simplefilter('error', Image.DecompressionBombWarning)
            with Image.open(path) as source:
                if source.format not in {'PNG', 'JPEG'} or getattr(source, 'n_frames', 1) != 1:
                    raise ValueError('Only still PNG/JPEG images are supported')
                if source.width * source.height > 16_000_000:
                    raise ValueError('Input exceeds 16 million pixels')
                upright = ImageOps.exif_transpose(source)
                rgba = upright.convert('RGBA')
                flat = Image.new('RGBA', rgba.size, 'white')
                flat.alpha_composite(rgba)
                thumb = ImageOps.contain(flat.convert('RGB'), (280, 180))
                thumbnails.append(thumb)
                records.append({'id': f'C{index:02}', 'file': path.name,
                                'sha256': digest, 'upright_size': list(upright.size),
                                'thumbnail_size': list(thumb.size)})
    # All inputs decode before creating any output. Originals are never saved.
    rows = (len(files) + 2) // 3
    sheet = Image.new('RGB', (900, rows * 230), '#151526')
    draw = ImageDraw.Draw(sheet)
    for index, (record, thumb) in enumerate(zip(records, thumbnails)):
        x, y = (index % 3) * 300, (index // 3) * 230
        sheet.paste(thumb, (x + (300 - thumb.width) // 2,
                            y + 10 + (180 - thumb.height) // 2))
        draw.text((x + 12, y + 200), record['id'], fill='white')
    # Exclusive creation refuses to overwrite a file created during processing.
    with output.open('xb') as handle:
        sheet.save(handle, format='PNG')
    return records


if __name__ == '__main__':
    if len(sys.argv) != 3:
        raise SystemExit('Usage: python contact_sheet.py INPUT_DIRECTORY NEW_OUTPUT.png')
    print(json.dumps(build_sheet(sys.argv[1], sys.argv[2]), indent=2))

Save as test_contact_sheet.py:

from pathlib import Path
import tempfile
import unittest
from PIL import Image
from contact_sheet import build_sheet


class SheetTests(unittest.TestCase):
    def setUp(self):
        self.temp = tempfile.TemporaryDirectory()
        self.addCleanup(self.temp.cleanup)
        self.root = Path(self.temp.name)
        self.inputs = self.root / 'inputs'
        self.inputs.mkdir()
        self.output = self.root / 'sheet.png'

    def image(self, name='a.png', size=(600, 300), mode='RGB', color='red'):
        path = self.inputs / name
        Image.new(mode, size, color).save(path)
        return path

    def test_landscape_is_contained(self):
        self.image()
        self.assertEqual(build_sheet(self.inputs, self.output)[0]['thumbnail_size'], [280, 140])

    def test_portrait_is_contained(self):
        self.image(size=(300, 600))
        self.assertEqual(build_sheet(self.inputs, self.output)[0]['thumbnail_size'], [90, 180])

    def test_order_and_sources_unchanged(self):
        b = self.image('b.png'); a = self.image('a.png')
        before = {p.name: p.read_bytes() for p in (a, b)}
        records = build_sheet(self.inputs, self.output)
        self.assertEqual([r['file'] for r in records], ['a.png', 'b.png'])
        self.assertEqual(before, {p.name: p.read_bytes() for p in (a, b)})

    def test_transparency_is_white(self):
        self.image(size=(280, 180), mode='RGBA', color=(255, 0, 0, 0))
        build_sheet(self.inputs, self.output)
        with Image.open(self.output) as im:
            self.assertEqual(im.getpixel((150, 100)), (255, 255, 255))

    def test_orientation_is_applied(self):
        exif = Image.Exif(); exif[274] = 6
        Image.new('RGB', (600, 300), 'red').save(self.inputs / 'a.jpg', exif=exif)
        self.assertEqual(build_sheet(self.inputs, self.output)[0]['upright_size'], [300, 600])

    def test_empty_is_rejected(self):
        with self.assertRaises(ValueError): build_sheet(self.inputs, self.output)

    def test_too_many_is_rejected(self):
        for n in range(13): self.image(f'{n}.png', (2, 2))
        with self.assertRaises(ValueError): build_sheet(self.inputs, self.output)

    def test_existing_output_is_preserved(self):
        self.image(); self.output.write_bytes(b'keep')
        with self.assertRaises(ValueError): build_sheet(self.inputs, self.output)
        self.assertEqual(self.output.read_bytes(), b'keep')

    def test_corrupt_input_leaves_no_output(self):
        self.image(); (self.inputs / 'bad.png').write_bytes(b'not an image')
        with self.assertRaises(OSError): build_sheet(self.inputs, self.output)
        self.assertFalse(self.output.exists())

    def test_large_dimensions_are_rejected(self):
        self.image(size=(4001, 4000))
        with self.assertRaises(ValueError): build_sheet(self.inputs, self.output)

    def test_two_rows_dimensions(self):
        for n in range(4): self.image(f'{n}.png', (20, 20))
        build_sheet(self.inputs, self.output)
        with Image.open(self.output) as im: self.assertEqual(im.size, (900, 460))

    def test_wrong_output_extension(self):
        self.image()
        with self.assertRaises(ValueError): build_sheet(self.inputs, self.root / 'sheet.jpg')


if __name__ == '__main__':
    unittest.main()

Save as demo_sheet.py:

"""Create six explicitly synthetic geometry samples, then build their review sheet."""
from pathlib import Path
import json
from PIL import Image, ImageDraw
from contact_sheet import build_sheet

folder = Path('demo-inputs')
folder.mkdir(exist_ok=False)
sizes = [(600, 300), (300, 600), (400, 400), (900, 300), (300, 900), (500, 400)]
colors = ['#ff6b4a', '#845ef7', '#12b886', '#fcc419', '#339af0', '#f06595']
for n, (size, color) in enumerate(zip(sizes, colors), 1):
    image = Image.new('RGB', size, color)
    draw = ImageDraw.Draw(image)
    w, h = size
    draw.ellipse((w//4, h//4, 3*w//4, 3*h//4), fill='#151526')
    draw.rectangle((0, 0, w-1, h-1), outline='white', width=8)
    image.save(folder / f'candidate-{n:02}.png')
records = build_sheet(folder, 'review-sheet.png')
Path('manifest.json').write_text(json.dumps(records, indent=2), encoding='utf-8')
with Image.open('review-sheet.png') as sheet:
    print(json.dumps({'sheet_size': list(sheet.size), 'count': len(records),
                      'thumbnail_sizes': [r['thumbnail_size'] for r in records]}, indent=2))

Run these commands in that new directory. The demo requires its output names to be absent; use a fresh directory for another run.

python -X utf8 -m unittest -v test_contact_sheet.py
python -X utf8 demo_sheet.py

The tests report 12 passing methods. The demo prints this actual summary and saves review-sheet.png plus manifest.json:

{
  "sheet_size": [
    900,
    460
  ],
  "count": 6,
  "thumbnail_sizes": [
    [
      280,
      140
    ],
    [
      90,
      180
    ],
    [
      180,
      180
    ],
    [
      280,
      93
    ],
    [
      60,
      180
    ],
    [
      225,
      180
    ]
  ]
}

Use it with your own candidates

Call contact_sheet.py with two positional arguments: the candidate directory and a new .png output path. Quote paths containing spaces. Its JSON goes to standard output; save that output as your review manifest. Keep the output outside the input folder so it cannot become a candidate in a later run. Keep each run in its own folder rather than mixing old sheets with new candidates.

The loader permits 1–12 still PNG/JPEG files, each at most 10,000,000 bytes and 16,000,000 pixels. It rejects symlinks, refuses existing output paths and decodes all candidates before opening its output. A corrupt input therefore stops this example without producing a sheet. Other file extensions are ignored; check the returned count so an unsupported export does not disappear from your review unnoticed.

The tool never saves an original. Tests compare original bytes before and after the operation. It does save a new review image, and a disk-write failure can leave that output incomplete: this is exclusive creation, not a transactional publication system. Delete or replace an incomplete output only after inspecting it yourself.

Give the reviewer an explicit decision

Record a shortlist ID, exact filename, source hash, intended placement, reasons and required changes. A useful note is: “C02 has room for the headline, but review the original for product accuracy before approving.” This is an illustrative note, not a result from an actual campaign. Do not mistake a source hash for a rights check, authenticity proof or model provenance record.

For AI-generated assets, retain the original export and relevant generation record separately. If the artwork changes, regenerate the sheet and manifest instead of reusing an old ID. After selecting a candidate, follow the separate campaign release review. To turn approved art into motion, see the FFmpeg promo workflow.

What was double-checked

Twelve executed test methods cover landscape/portrait containment, sorted IDs and unchanged sources, white transparency compositing, EXIF rotation, empty/excessive candidate counts, existing-output preservation, corrupt input without output, pixel limits, row geometry and output extension. The demo CLI was also executed. Final article code is extracted into a disposable directory and rerun; after publication the public blocks are compared with independently preserved hashes and rerun again.

This is a bounded tool for trusted local review files, not a hostile-upload service. It is not hardened against concurrent source changes, all filesystem aliases or all malicious decoder inputs. Extremely narrow images can fail resizing when the contained dimension rounds to zero; such failures need a separate input policy rather than silent cropping. It does not preserve animation, embedded metadata, source color profiles or print color accuracy; white compositing can change how transparent art is perceived. No creative application, image model, asset server, customer workflow or production deployment was integrated or tested. There is no claim of time savings or campaign performance.

Jason Ead publishes AlgoThoughts AI News, connecting practical creative engineering with My Biz Heroes: websites, automation, workflows, AI tools and training. Explore Jason’s work. The raccoon art-director cover is generated conceptual artwork, not a screenshot or test result. The interior layout map reports the executed synthetic fixture.