Short answer: decode the camera file, apply its EXIF orientation once, strip that tag, and generate every avatar derivative from the normalized pixels. For a B2B SaaS that turns prompts and customer avatars into short promo videos, the least complex storage policy is one normalized master plus the derivatives the product actually serves.
The bill is bytes multiplied by retention time and replica count. If one 8 MB phone photo becomes a 6 MB normalized master, a 400 KB profile image, an 80 KB thumbnail, and temporary compositor inputs, keeping all of them indefinitely makes the orientation fix look cheap while storage grows. Normalize before the cache key and video composition, then expire reproducible intermediates.
Why do uploaded camera photos appear sideways after processing?
JPEG pixels may be stored in one physical orientation while EXIF metadata tells a viewer how to display them. A gallery can honor that instruction; a derivative worker can decode the raster, resize it, and omit the instruction. The avatar then appears sideways although the upload looked correct.
Save the decoded dimensions, EXIF orientation, format, and a digest at ingestion. If the derivative dimensions reflect the unrotated raster and its tag is absent, the worker copied pixels without applying the transform. If pixels are upright but the tag still requests rotation, a later renderer may rotate twice.
Orientation has eight values, including mirrored cases. Test all eight. Log a job identifier and numeric orientation, not the complete EXIF map; avatar metadata deserves the same restraint as OTP delivery logs.
This is the trap.
from pathlib import Path
from PIL import Image
def inspect_image(path: Path) -> dict[str, object]:
with Image.open(path) as image:
return {
"format": image.format,
"size": image.size,
"orientation": image.getexif().get(274, 1),
}
Normalize once, before caching
Pillow's ImageOps.exif_transpose applies the encoded operation and removes the orientation data. Order matters: decode, transpose, convert, then resize. A thumbnail created first was constrained in the wrong coordinate system.
from io import BytesIO
from PIL import Image, ImageOps
def normalized_jpeg(source: bytes, max_side: int = 2048) -> bytes:
with Image.open(BytesIO(source)) as candidate:
candidate.verify()
with Image.open(BytesIO(source)) as opened:
upright = ImageOps.exif_transpose(opened)
rgb = upright.convert("RGB")
rgb.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
output = BytesIO()
rgb.save(output, format="JPEG", quality=85, optimize=True)
return output.getvalue()
The second open is deliberate because verify() leaves the first object unsuitable for decoding. Enforce byte and pixel limits at upload, and inspect encoded content instead of trusting a filename or declared MIME type.
Make those normalized bytes the source for the square avatar, preview, and promo-video compositor. Include a transformation policy version and source digest in each cache key. A behavior change then creates a cache miss instead of mixing old and new pixels under one key.
import hashlib
def derivative_key(source: bytes, policy: str, size: int) -> str:
digest = hashlib.sha256(source).hexdigest()
return f"avatars/{digest}/{policy}/{size}.jpg"
Do not apply orientation again inside the video renderer. The media contract should say: physically upright pixels, with no pending EXIF orientation transform.
Which bytes deserve long retention?
Use byte-days before applying any provider rate. This exposes the stable cost driver without turning the decision into a price comparison.
| Object class | Count | Example bytes | Retention | Regenerable |
|---|---|---|---|---|
| Camera original | 1 | 8,000,000 | 7-day review | No, after deletion |
| Normalized master | 1 | 6,000,000 | 365 days | While original exists |
| Avatar derivative | 1 | 400,000 | 365 days | Yes |
| Thumbnail | 1 | 80,000 | 30 days | Yes |
| Compositor input | 1 or more | Measure it | Job lifetime | Yes |
from dataclasses import dataclass
@dataclass(frozen=True)
class StoredObject:
bytes_per_object: int
objects_per_upload: int
retained_days: int
def byte_days(item: StoredObject, uploads: int) -> int:
return item.bytes_per_object * item.objects_per_upload * item.retained_days * uploads
portfolio = [
StoredObject(8_000_000, 1, 7),
StoredObject(6_000_000, 1, 365),
StoredObject(400_000, 1, 365),
StoredObject(80_000, 1, 30),
]
print(sum(byte_days(item, 10_000) for item in portfolio))
The normalized master dominates this example because it lives for 365 days. A team may instead retain originals for a compliance-defined window and regenerate masters after policy changes. Deletion, legal hold, and user erasure must propagate to every copy.
There is a real cost to deletion. Once the original expires, you cannot recover omitted metadata, reprocess it with a better decoder, or prove exactly which bytes arrived. Deleting the master makes cache recovery depend on the original. Storage reduction purchases weaker forensics and slower recovery.
Create fixtures with an asymmetric colored marker in each corner, encode orientation values 1 through 8, and assert final corner positions and dimensions. Symmetric headshots hide mirrored failures. Also test no tag, malformed metadata, a truncated body, and a valid image with the wrong declared content type.
Retries must yield identical normalized bytes. Queues retry.
Deploy with a versioned transform policy. Route new uploads through it, regenerate hot avatars on read or through a bounded backfill, and count outcomes by source orientation and policy version. Avoid object keys as metric labels. A small quarantined sample may help diagnosis only under an explicit retention rule.
Keep one normalized master for the product retention period, retain served sizes according to cache demand, and delete compositor inputs after the job reaches a terminal state. Keep the camera original only for a documented recovery or compliance window. This changes the largest controllable term: duplicate bytes held too long.
The limitation is intentional. A future decoder cannot repair an expired original. This policy is not suitable when audit rules require the exact arrival bytes, when later metadata extraction is part of the product, or when the normalization library cannot decode a supported input format. In those cases, retain an access-controlled original for the required period and account for that copy explicitly. The trade-off is more storage and a wider privacy surface, but it preserves forensic and reprocessing options. Counter normalization risk with eight-orientation fixtures, versioned transforms, staged deployment, and a review window long enough to catch anomalies.
Further reading
- https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
- https://www.cipa.jp/std/documents/e/DC-008-Translation-2019-E.pdf
- https://pillow.readthedocs.io/en/stable/reference/ImageOps.html#PIL.ImageOps.exif_transpose
- https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.verify
Top comments (0)