A thumbnail service should normalize orientation before it measures, crops, or resizes an image. A file can store landscape-shaped pixels and an EXIF instruction that tells a viewer to display them upright. Ignoring that instruction creates disagreement between your preview and your export.
This walkthrough implements a small Pillow pipeline and tests all eight EXIF orientation values. It is not a model-quality benchmark, and it does not assume every AI download contains EXIF.
1. Define the output contract
The output is an upright RGB PNG, at most 480 pixels on either side, without inherited EXIF or XMP. The original remains unchanged. Transparent pixels are composited over white; choose a different explicit background if that is wrong for your product. This deliberately discards metadata rather than promising to preserve color profiles, location, or provenance fields.
This is an actual FLUX.1 Krea [dev] example from the current image workspace, not a deliberately broken orientation fixture. The synthetic tests below create the metadata cases separately, so the illustration does not falsely claim a model export bug.
2. Normalize, then resize
Install Pillow in an isolated environment and save this as normalize.py. The code was checked with Pillow 12.3.0. The official ImageOps documentation specifies that exif_transpose applies the orientation and removes its orientation data; with the default in_place=False, it returns a new image.
from pathlib import Path
from PIL import Image, ImageOps
def thumbnail(source: Path, destination: Path, box=(480, 480)):
if source.resolve() == destination.resolve():
raise ValueError('keep the original file')
with Image.open(source) as raw:
upright = ImageOps.exif_transpose(raw)
upright.thumbnail(box, Image.Resampling.LANCZOS)
# A fresh RGB canvas intentionally omits inherited EXIF/XMP metadata.
clean = Image.new('RGB', upright.size, 'white')
if 'A' in upright.getbands():
clean.paste(upright, mask=upright.getchannel('A'))
else:
clean.paste(upright.convert('RGB'))
clean.save(destination, 'PNG')
Do not compute a portrait crop from raw.size first. Values 5 through 8 can swap the effective width and height. Also do not rotate pixels and then copy the old orientation tag into the output: a later viewer may apply the transform a second time.
A fresh canvas makes the metadata policy explicit. It is not a color-managed conversion: if you need an ICC workflow, add and test that separately. Treat decoding byte/pixel limits, animation, and unsupported formats as separate input-validation concerns before exposing this to untrusted uploads.
3. Test the convention, not just the dimensions
The test starts with a non-square image and a green corner pixel. Dimensions alone cannot catch mirroring. Save the following next to the function and run python test_normalize.py.
from pathlib import Path
from tempfile import TemporaryDirectory
from PIL import Image, ImageOps
from normalize import thumbnail
with TemporaryDirectory() as tmp:
p=Path(tmp)
for orientation in range(1,9):
image=Image.new('RGB',(12,8),'red')
image.putpixel((0,0),(0,255,0))
exif=Image.Exif();exif[274]=orientation
image.save(p/'in.png',exif=exif)
with Image.open(p/'in.png') as raw:
expected=ImageOps.exif_transpose(raw).convert('RGB')
thumbnail(p/'in.png',p/'out.png',box=(48,48))
with Image.open(p/'out.png') as actual:
assert actual.size==expected.size
assert actual.tobytes()==expected.tobytes()
assert actual.getexif().get(274) is None
thumbnail(p/'out.png',p/'again.png',box=(48,48))
assert (p/'again.png').read_bytes()==(p/'out.png').read_bytes()
for name in ['cover.jpg','workspace.jpg']:
thumbnail(Path(__file__).parent/name,p/'thumb.png')
with Image.open(p/'thumb.png') as image:assert max(image.size)<=480
print('8 orientation cases, metadata removal, idempotence, 2 real-image smoke tests passed')
For a portable test without the two example JPEGs, remove only the final real-image smoke-test loop. The eight synthetic cases remain self-contained. The assertions compare against Pillow's documented transform; they test our pipeline's integration and metadata handling, not an independent proof of Pillow's implementation.
The greenhouse example is a second actual output, not a before/after version of the coastal portrait. Both smoke tests completed within the 480-pixel box. All eight synthetic orientation cases passed pixel comparisons, orientation-tag removal, and a second-pass idempotence check.
4. Connect it to an image workflow without mixing responsibilities
The Voor page currently exposes FLUX.1 Krea [dev], Prompt, Canvas, one-to-four Images, Advanced Settings, Public watermarked visibility, and Generate. The observed one-image quote was 7 credits on September 9; it is not a promise of free generation or a permanent price. Presets such as Coastal fashion and Botanical portrait provide complete briefs. Our screenshots use existing results; the pipeline does not call a model API or spend generation credits.
Keep the generation prompt and approval record outside the deliberately metadata-clean thumbnail. If you create a new source image, preserve its original download first, then feed a copy through this post-processing boundary. The useful guarantee is deterministic orientation handling—not that a generated scene, person, or texture is factually correct.


Top comments (0)