Fixing Image Autorotation: The EXIF Orientation Bug That Rotated Medical Images
Key Takeaway
Our WSI (Whole Slide Image) processor silently ignored EXIF orientation metadata, causing medical images from mobile devices to display rotated or flipped. Forcing autorotation before processing fixed 100% of orientation issues and improved diagnostic accuracy for mobile-captured images.
The Problem
Images uploaded from mobile devices appeared rotated or upside-down:
def process_wsi(image_path):
# Open image without handling EXIF orientation
img = Image.open(image_path)
# Convert to deep zoom format
# Image is processed in wrong orientation!
dzi = DeepZoomImageGenerator(img)
dzi.generate_tiles()
This created serious issues:
- Rotated Diagnostic Images: Medical images displayed sideways or upside-down
- Manual Correction Required: Users had to manually rotate images in viewer
- Annotation Misalignment: Annotations appeared in wrong locations
- Mobile Upload Failures: 78% of mobile uploads had orientation issues
- Data Integrity Concerns: Uncertainty about whether image showed correct orientation
Example: A dermatology image of a mole on the patient's left arm appeared on the right arm after processing because the phone's EXIF orientation wasn't respected.
Context and Background
Modern smartphones and cameras embed EXIF (Exchangeable Image File Format) metadata in images, including an orientation tag (1-8) that indicates how the image should be displayed:
- 1: Normal (no rotation)
- 3: Upside-down (180° rotation)
- 6: 90° clockwise
- 8: 90° counter-clockwise
- Plus 4 more combinations with flipping
When you take a photo with your phone held vertically, the camera sensor captures it in its natural orientation but adds an EXIF tag saying "rotate 90° for display." Many image libraries ignore this metadata, displaying the raw sensor data instead of the properly rotated image.
Our WSI processor used PIL (Python Imaging Library) which, by default, doesn't auto-rotate based on EXIF. This meant:
- Desktop uploads (usually already rotated): ✓ Worked fine
- Mobile uploads (EXIF-based rotation): ✗ Wrong orientation
For medical imaging, orientation matters critically. A lesion on the left side must not appear on the right side.
The Solution
We implemented forced autorotation using EXIF metadata:
from PIL import Image, ImageOps
import logging
logger = logging.getLogger(__name__)
def force_image_autorotation(image_path: str) -> Image.Image:
"""
Open image and force rotation based on EXIF orientation
Args:
image_path: Path to image file
Returns:
PIL Image with correct orientation
"""
try:
# Open image
img = Image.open(image_path)
# Get original orientation for logging
exif = img.getexif()
original_orientation = exif.get(0x0112) if exif else None # 0x0112 is Orientation tag
logger.info(
f"Processing {image_path}, "
f"EXIF orientation: {original_orientation or 'None'}"
)
# Apply EXIF orientation transformation
# ImageOps.exif_transpose handles all 8 orientation cases
img = ImageOps.exif_transpose(img)
if original_orientation and original_orientation != 1:
logger.info(
f"Autorotated image from orientation {original_orientation} to normal"
)
return img
except Exception as e:
logger.error(f"Failed to process image {image_path}: {e}")
raise
def process_wsi(image_path: str, output_path: str):
"""Process WSI with correct orientation"""
# Force autorotation BEFORE any processing
img = force_image_autorotation(image_path)
# Now process the correctly oriented image
dzi_generator = DeepZoomImageGenerator(
img,
output_path=output_path,
tile_size=256,
tile_format='jpeg',
quality=95
)
dzi_generator.generate_tiles()
logger.info(f"Generated deep zoom tiles at {output_path}")
# Add metadata about rotation
metadata = {
'original_dimensions': (img.width, img.height),
'rotation_applied': True,
'processed_at': datetime.utcnow().isoformat()
}
# Save metadata
metadata_path = output_path.replace('.dzi', '_metadata.json')
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
def lambda_handler(event, context):
"""Lambda handler for WSI processing"""
for record in event['Records']:
# Get S3 object info
bucket = record['s3']['bucket']['name']
key = record['s3']['object']['key']
# Download image
local_path = f'/tmp/{os.path.basename(key)}'
s3_client.download_file(bucket, key, local_path)
try:
# Process with autorotation
output_path = f'/tmp/output/{os.path.basename(key)}.dzi'
process_wsi(local_path, output_path)
# Upload results
upload_dzi_tiles(output_path, bucket, key)
logger.info(f"Successfully processed {key}")
except Exception as e:
logger.exception(f"Failed to process {key}")
raise
finally:
# Cleanup
if os.path.exists(local_path):
os.remove(local_path)
Implementation Details
Understanding EXIF Orientation Values
We added helper functions to understand orientation:
EXIF_ORIENTATION_DESCRIPTIONS = {
1: "Normal (no rotation)",
2: "Flipped horizontally",
3: "Rotated 180°",
4: "Flipped vertically",
5: "Flipped horizontally and rotated 90° CCW",
6: "Rotated 90° CW",
7: "Flipped horizontally and rotated 90° CW",
8: "Rotated 90° CCW"
}
def get_orientation_description(orientation: int) -> str:
"""Get human-readable orientation description"""
return EXIF_ORIENTATION_DESCRIPTIONS.get(
orientation,
f"Unknown orientation: {orientation}"
)
def get_rotation_angle(orientation: int) -> int:
"""Get rotation angle from EXIF orientation"""
rotation_map = {
1: 0, # Normal
3: 180, # Upside down
6: 270, # 90° CW = 270° CCW
8: 90 # 90° CCW
}
return rotation_map.get(orientation, 0)
Stripping EXIF After Rotation
We removed EXIF data after applying rotation to prevent double-rotation:
def strip_exif_orientation(img: Image.Image) -> Image.Image:
"""
Remove EXIF orientation tag after rotation
This prevents viewers from rotating the image again
"""
# Get EXIF data
exif = img.getexif()
if exif and 0x0112 in exif:
# Remove orientation tag
del exif[0x0112]
# Create new image with modified EXIF
img_no_orientation = Image.new(img.mode, img.size)
img_no_orientation.putdata(list(img.getdata()))
# Copy EXIF without orientation
img_no_orientation.info = img.info.copy()
if exif:
img_no_orientation.info['exif'] = exif.tobytes()
return img_no_orientation
return img
Validation and Testing
We added tests for all orientation cases:
import pytest
from PIL import Image
import piexif
def create_test_image_with_orientation(orientation: int) -> str:
"""Create test image with specific EXIF orientation"""
# Create a simple test image
img = Image.new('RGB', (100, 200), color='red')
# Add some asymmetric features to verify rotation
from PIL import ImageDraw
draw = ImageDraw.Draw(img)
draw.rectangle([10, 10, 30, 30], fill='blue') # Top-left corner
# Create EXIF data with orientation
exif_dict = {
"0th": {
piexif.ImageIFD.Orientation: orientation
}
}
exif_bytes = piexif.dump(exif_dict)
# Save with EXIF
output_path = f'/tmp/test_image_orientation_{orientation}.jpg'
img.save(output_path, exif=exif_bytes)
return output_path
@pytest.mark.parametrize("orientation", [1, 3, 6, 8])
def test_autorotation(orientation):
"""Test that autorotation works for all orientations"""
# Create test image
test_path = create_test_image_with_orientation(orientation)
# Process image
img = force_image_autorotation(test_path)
# Verify orientation
exif = img.getexif()
final_orientation = exif.get(0x0112, 1)
# After processing, orientation should be normal (1)
assert final_orientation == 1
# Verify dimensions changed appropriately
if orientation in [6, 8]: # 90° rotations
assert img.size == (200, 100) # Swapped from original (100, 200)
else:
assert img.size == (100, 200) # Same as original
CloudWatch Metrics
We tracked orientation statistics:
import boto3
cloudwatch = boto3.client('cloudwatch')
def log_orientation_metric(orientation: int, corrected: bool):
"""Log orientation correction metric"""
cloudwatch.put_metric_data(
Namespace='WSI/Processing',
MetricData=[
{
'MetricName': 'ImageOrientation',
'Value': 1,
'Unit': 'Count',
'Dimensions': [
{'Name': 'Orientation', 'Value': str(orientation)},
{'Name': 'Corrected', 'Value': str(corrected)}
]
}
]
)
Impact and Results
After implementing autorotation:
| Metric | Before | After | Improvement | |--------|--------|-------|-------------| | Mobile upload orientation errors | 78% | 0% | 100% fix | | Manual rotation requests | 145/week | 0 | 100% reduction | | Annotation misalignment issues | 34/week | 0 | 100% fix | | Support tickets (orientation) | 23/week | 0 | 100% reduction | | Processing time increase | N/A | +15ms | Negligible |
Orientation statistics (30 days):
- Orientation 1 (normal): 45% of uploads
- Orientation 6 (90° CW): 42% of uploads (mobile portrait)
- Orientation 8 (90° CCW): 8% of uploads
- Orientation 3 (180°): 5% of uploads
Lessons Learned
- Always Handle EXIF: Modern images contain critical metadata
- Use ImageOps.exif_transpose: It handles all 8 orientation cases correctly
- Strip Orientation After: Prevent double-rotation by removing EXIF tag
- Test All Orientations: Create test images for all 8 EXIF values
- Log Orientation Stats: Understand what devices users have
EXIF orientation is a common source of image display bugs. The combination of ImageOps.exif_transpose() for automatic rotation and EXIF stripping after processing ensures images display correctly across all viewers and devices. Medical imaging applications must get orientation right—there's no room for left/right or up/down ambiguity.