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
resultsays so. Asklooks_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
Trueand has exactly one bit of it, so a soft edge is snapped rather than composited. If that matters, flatten deliberately withflatten_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
pHYschunk 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
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)
| File | Size | Uploaded | |
|---|---|---|---|
| mullion-0.2.1.tar.gz | 141.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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