Skip to main content

Mullion — one correct way to open an image

Mullion

One correct way to open an image — and to write it back out. EXIF orientation applied, transparency resolved rather than dropped at both ends, and a background cleaner that does not eat the subject.

A mullion is the upright that divides a window into panes — one stone member, every pane set against it. That is the argument of this library: an application that opens user images in six places gets six different answers, and the fix is one member they all lean on.

from mullion import contain, encode, open_bytes, open_path, watermark

image = open_bytes(upload.read())          # RGB, upright, no black rectangle
avatar = open_path(path, keep_alpha=True)  # RGBA preserved

hero = watermark(contain(image, (1280, 720)), "example.com")
body = encode(hero, "JPEG", quality=88)    # composited, not blackened

One caveat worth knowing before you build on that first line: the adapters open and close the source for you, so the image they hand back cannot answer anything about the file that arrived — n_frames reads 1 even for an animation, and format reads None. Want pixels, use an adapter; want to know what arrived, use normalize and open the source yourself. The mullion.source section below has the wrong and right versions side by side.

The defect this exists for

Image.open(...).convert("RGB") is the spelling everyone reaches for, and it is wrong in two ways that both render as a plausible picture rather than an error.

Transparency composites against black. convert("RGB") on an RGBA image drops the alpha channel and keeps whatever RGB sits underneath. In a PNG written by almost every tool, fully transparent pixels carry (0, 0, 0) — so a cutout lands on a white page inside a black box.

>>> from PIL import Image
>>> logo = Image.open("cutout.png")          # RGBA, transparent corners
>>> logo.convert("RGB").getpixel((0, 0))
(0, 0, 0)
>>> open_path("cutout.png").getpixel((0, 0))
(255, 255, 255)

The same is true of LA (greyscale + alpha) and of palette P images carrying a transparency entry — which is what most "small" PNGs are saved as, and the one that is easiest to miss, because the alpha is not in the mode string at all. It is a key in info.

EXIF orientation is ignored. A photo shot in portrait on a phone is stored landscape with a tag telling the viewer to rotate it. Pillow does not apply that tag on open. Because placement is usually aspect-preserving, a wrongly-rotated photo comes out small and sideways rather than obviously broken.

Neither is loud. The response is a 200, the picture renders, the layout is right. A status-code test cannot see it and a reviewer looking at one image at a time will not catch it.

This is measured, not hypothetical. Across one production codebase the same transparent PNG came back on a black rectangle through three upload paths and correctly through four others, because each had been written independently.

Install

pip install mullion

Python 3.11+, and Pillow is the only dependency.

Importing the package costs nothing — Pillow is imported inside the functions that need it, so a service that imports at module scope and opens images rarely does not pay for it at start-up.

What it does

mullion.source — one correct open

from mullion import normalize, open_bytes, open_path, has_transparency, flatten_onto

normalize(pil_image)                       # the core: any Image in, upright RGB out
normalize(pil_image, keep_alpha=True)      # RGBA out instead
normalize(pil_image, matte=(18, 18, 22))   # composite onto something other than white

open_bytes(data)                           # adapter: bytes from object storage or an upload
open_path("photo.jpg")                     # adapter: a filesystem path

normalize is the whole rule and the adapters are three lines each. That split is deliberate: a path-only API is unusable from a web service holding bytes, and a bytes-only API forces a batch tool to read every file into memory first.

keep_alpha=False composites transparency onto matte and returns RGB — what an opaque destination wants. keep_alpha=True returns RGBA, for a caller that will composite itself or write a cutout. Neither mutates the input.

The adapters close the source before you see it. Everything here returns a new image, so nothing you could have read off the original survives on the result — n_frames is 1 whatever the source held, and format is None. With normalize that costs nothing, because you still hold the source and can read it either side of the call. With open_bytes and open_path there is no source to hold, so a caller that needs to inspect what arrived has to open it itself:

# Wrong, and quiet: every animation reports one frame, the re-encode
# succeeds, and only the motion is missing.
image = open_bytes(payload)
if getattr(image, "n_frames", 1) > 1:
    raise Refused("animated uploads are not accepted")

# Right: read the source while you still have it.
with Image.open(BytesIO(payload)) as source:
    if getattr(source, "n_frames", 1) > 1:
        raise Refused("animated uploads are not accepted")
    image = normalize(source)

The rule in one line: want pixels, use an adapter; want to know what arrived, use normalize and open it yourself.

mullion.resize — fit to a box, contain or cover

Pillow has three spellings for this and they do not agree. thumbnail mutates in place and returns None, so smaller = image.thumbnail(box) binds nothing and edits the image somebody else is holding. resize always scales, so a caller who meant "no bigger than 800px" upscales a 200px upload and ships a blurred picture. And ImageOps.fit is named fit and crops.

So these two are named for what they do to the content, in the vocabulary CSS settled on:

from mullion import contain, cover

thumb = contain(image, (400, 400))            # all of it, may leave space
card = cover(image, (1200, 630))              # fills exactly, may cut
face = cover(image, (400, 400), centering=(0.5, 0.0))   # keep the top

contain does not upscale unless you pass upscale=True, and both return a new image. contain is built on thumbnail, so moving an existing image.thumbnail(box, LANCZOS) call onto it gives the same pixels, not merely a similar picture.

mullion.background — clean a near-white background

A "white" background is rarely #FFFFFF. It is #FBFBFA from a scanner, #F7F8F9 from a phone's auto white balance, or a ring of #FDFDFD compression noise around a cutout. Each reads as a faint rectangle around the image.

from mullion import clean_background, keeps_alpha

mode = "flatten"                                    # or "transparent", or "off"
image = open_path(path, keep_alpha=keeps_alpha(mode))
cleaned, result = clean_background(image, mode=mode)

print(result.message)
# Cleaned background (63% of the image).
# ...or: No white background detected; image left unchanged.

Two rules make this safe to run over everything:

  • Only what touches the border is cleaned. A global "brighter than threshold becomes white" pass is the obvious implementation and it eats the subject — the highlight on a white mug, the page of an open book, a bride's dress, the sky behind a building. This flood-fills inward from the edges, so a pixel is cleaned only when there is a continuous near-white path from it to the frame. Interior highlights are unreachable and survive.
  • Each image decides whether it has a background at all. An image whose border is not near-white — a full-bleed photograph, a dark render — comes back untouched rather than partly eaten, and result says so. Ask looks_like_white_background() directly if you want the verdict without the work.

There is deliberately no open_and_clean convenience. It would have to restate every keyword argument of both halves, and keeps_alpha() already names the one coupling between them — opening without it flattens an existing cutout onto white before cleaning, which leaves a pale fringe on the anti-aliased edges.

mullion.watermark — a mark that survives a bright sky

from mullion import watermark

marked = watermark(hero, "example.com")       # bottom-right, RGBA out

Sized against the image's shorter side with clamps at both ends, so it is neither illegible on a thumbnail nor billboard-sized on a large render, and so a portrait and a landscape version of the same picture get the same visual weight. Drawn over a soft offset halo in the opposite ink — without it, white type disappears into a bright sky and reads as a rendering fault.

Everything about the look is a WatermarkStyle, which is frozen and has the measured defaults; pass your own for a different corner, ink or scale, or pass font= to bring your own face. Empty text returns the image unchanged, because "do not mark" is a legitimate thing for a caller's configuration to say.

It takes and returns an image rather than bytes on purpose: the mark belongs between the resize and the encode. Marking first resamples the type along with the picture and softens it; marking after the encode means decoding the output again.

mullion.encode — the black rectangle, on the way out

An RGBA image handed to a JPEG encoder raises cannot write mode RGBA as JPEG, which is loud and therefore fine — except for what it provokes. The obvious repair reads like a type fix and is the defect this library exists for, reintroduced at the other end of the pipeline:

image.convert("RGB").save(buffer, format="JPEG")   # black rectangle, again

encode is the pair to open_bytes. The caller says what they are writing, not what mode the image needs to be in:

from mullion import encode, supports_alpha

body = encode(image, "JPEG", quality=88, optimize=True)  # composited, not blackened
body = encode(image, "WEBP", quality=82, method=6)       # alpha kept
body = encode(page_image, "PNG", dpi=(page.dpi, page.dpi))

supports_alpha("PNG")     # True
supports_alpha("JPEG")    # False

Which formats can carry alpha is asked of Pillow — by writing one transparent pixel and seeing whether the encoder objects — rather than answered from a table kept here, because the table would be wrong in both directions: AVIF depends on a plugin that may not be installed, and a format added in a later Pillow would go unrecognised by a library that thinks it knows them all.

"Can carry an alpha channel" is not "carries yours faithfully". GIF answers True and has exactly one bit of it, so a soft edge is snapped rather than composited. If that matters, flatten deliberately with flatten_onto.

mullion.page — page geometry and DPI

3000 px is 10 inches at 300 DPI and 41.7 inches at 72. Nothing in a file says which unless something writes the resolution into it.

from mullion import named_page_geometry, resolve_page_geometry

page = named_page_geometry("a4", dpi=300)
page.description          # '2480x3508px (8.27x11.69in at 300 DPI)'

page = resolve_page_geometry(page_width=8, page_height=10, unit="in", dpi=300)
canvas = Image.new("RGB", (page.width_px, page.height_px), "white")
canvas.save("out.png", dpi=(page.dpi, page.dpi))

The pixels and the DPI travel together, so the two cannot disagree. a4, a3, a5, letter, legal and tabloid are named; anything else is px, in or mm. This module imports nothing at all — it is arithmetic with the constants written down.

A PNG stores resolution in its pHYs chunk as pixels per metre, as an integer. 300 DPI is 11811.02… px/m, so it is stored as 11811 and reads back as 299.9994. That is the format, not a defect — compare with a tolerance.

What it is not

Not a general imaging toolkit. Pillow already is one, and this library is a thin correctness layer over it, not a wrapper around it — you get a real PIL.Image.Image back and do whatever you like with it.

Specifically out of scope: colour management, subject-aware or model-based background removal, and laying multiple images out on a page. That last one is where the line falls: this library handles one image; composing several is the application's job.

Two things that used to be on that list are now in the library, and the reason is worth stating rather than quietly editing out. Resizing was excluded because "ImageOps.fit and friends already do this well" — which was the same mistake this library exists to correct, one level up: there are three spellings, they disagree, and the disagreement is quiet. And encoding was excluded as "format conversion", which it is not: choosing what happens to an alpha channel the target format cannot hold is the same transparency decision open_bytes makes, arriving at the other end of the pipeline. Both were drawn the way a scope line usually is, from the shape of the code rather than from where the defects were.

Versioning

Pre-1.0. Nothing is stable, and any 0.x release may break any other — see VERSIONING.md, which is blunt about what that means. If you are building on 0.x, please open an issue saying so; the practical difference between an announced breaking change and a surprising one is knowing somebody is out there.

Contributing

CONTRIBUTING.md is candid about what a small team can promise. Security findings go to security@sigrix.io and never to a public issue — SECURITY.md.

Licence

Apache-2.0.

Metadata

Release files for mullion 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mullion 0.2.1
File Size Uploaded
mullion-0.2.1.tar.gz 141.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mullion 0.2.1
File Interpreter ABI Platform
mullion-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 174.0 kB

Release files / mullion-0.2.1.tar.gz

Download URL mullion-0.2.1.tar.gz
Size 141.3 kB
Tags Source
SHA-256 checksum
How to use checksums
487f86f58bac432a0600706a0e393a839740da00e75f27d68055a9a4acca9ab8
BLAKE2b-256 checksum
How to use checksums
ccfae032b15fb1f20de53d0b91e6026cb4ec6be9c2a741b31fa5a876a847fd48
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.

Transparency log

Release files / mullion-0.2.1-py3-none-any.whl

Download URL mullion-0.2.1-py3-none-any.whl
Size 32.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4e6901ecc2f7828739183c37b3cd6c8d920e50f8dcc9ad054894fd71ea6b1ede
BLAKE2b-256 checksum
How to use checksums
a612a3c9da9a34d49b002972ff93e400091f16ed945d244a4724f4a70f0755f0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.1 This release

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page