One of our Pi3D PictureFrame fans recently encountered an issue where very large images, such as panorama photos, caused their Raspberry Pi Zero running Pi3D PictureFrame to display a black screen.

Paddy suggested this is likely due to the Pi Zero’s limited memory.

A simple solution seems to be ensuring that all images are resized to fit within the display’s maximum width and height.

Tested on a Raspberry Pi 5 running OS Bookworm, February 2025. The following instructions assume you have either used the one-click install script or followed the same setup steps manually. Works with jpg, png, tiff, and heic files.

If your frame runs picframe3 on a Pi 4 or Pi 5, you do not need this script — and I would not run it. picframe3 handles oversized pictures itself: shrink_oversized is on by default, so a picture larger than the decode limit is opened once in a separate process and a screen-sized copy is kept in the cache. The photograph on your wall comes from that copy, and the original is never moved, changed or deleted. That last part is the important difference. The script below overwrites your files in place. If HEIC photographs are being skipped on picframe3, run picframe3 doctor — it prints the exact command to add HEIC support for your install. The 2026 build guide has the rest.

This article stays here for Pi Zero 2 W, Pi 2 and Pi 3 frames, which run pi3d and where a panorama really can take the frame down. That is the case it was written for.

So here is a small Python script that does exactly that:

  • Scans the Pictures folder and all subdirectories.
  • Resizes oversized images proportionally (no cropping) while preserving quality and retaining metadata.
  • Fixes potential color issues by ensuring RGB mode.
  • Detects newly added files and automatically checks if they need resizing.

Note: The image files are overwritten with the reduced size. There is no undo. Keep your originals somewhere else — on your computer, on a NAS, in whatever cloud you use — before you point this at a folder you care about. Run it once by hand on a copy of a few photographs and look at the result before you set it running as a service.

The Python script installation

Before running the script, install the necessary Python libraries with

pip install pillow watchdog pillow-heif

sudo apt install libheif1

Note: Before you do any “pip” stuff, don’t forget to always activate your Python virtual directory, in our case with source venv_picframe/bin/activate. Since Bookworm, a pip install outside a virtual environment is refused outright with error: externally-managed-environment, so this is no longer optional. (apt-get still works, but apt is the command Raspberry Pi OS documents now.)

This ensures that your Pi has the tools to recognize and process new images as they are added.

Create a new script with

nano resize_images.py

(No sudo — the file belongs in your own home directory.)

Copy and paste the following content into this script:

Set MAX_WIDTH and MAX_HEIGHT before you run this. The defaults below are 1080 × 768, which is not a size any frame I know of uses — it is neither 1080p (1920 × 1080) nor 4K (3840 × 2160). Leave them alone and every photograph in the folder is permanently shrunk to postcard size. Use your panel’s actual resolution:

  • a 1080p screen: MAX_WIDTH = 1920, MAX_HEIGHT = 1080
  • a 4K screen: MAX_WIDTH = 3840, MAX_HEIGHT = 2160
  • a portrait panel: swap the two numbers

The whole point of the script is to stop a photograph being far larger than anything the screen can show. Below the panel's own resolution you are throwing away quality for nothing.

import os
import time
import threading
import queue
from pathlib import Path
from PIL import Image
import pillow_heif
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

# Register HEIF opener with PIL
pillow_heif.register_heif_opener()

# Default configuration
PICTURES_FOLDER = os.path.expanduser("~/Pictures")
MAX_WIDTH = 1920   # your panel's width  - CHANGE THIS
MAX_HEIGHT = 1080  # your panel's height - CHANGE THIS
FILE_CHECK_RETRIES = 10
FILE_CHECK_INTERVAL = 0.1  # 100ms between checks
MAX_QUEUE_RETRIES = 5  # Maximum number of times to requeue a file

# Thread-safe queue to store new file paths
file_queue = queue.Queue()

def wait_for_file_stability(file_path, max_retries=FILE_CHECK_RETRIES, check_interval=FILE_CHECK_INTERVAL):
    """Wait for file to be completely written by checking if its size remains stable"""
    last_size = -1
    for _ in range(max_retries):
        try:
            current_size = os.path.getsize(file_path)
            if current_size == last_size and current_size > 0:
                return True
            last_size = current_size
            time.sleep(check_interval)
        except (OSError, FileNotFoundError):
            time.sleep(check_interval)
            continue
    return False

def resize_image(image_path, attempt=1):
    """ Resize image while maintaining aspect ratio and quality """
    try:
        print(f"Checking if file is ready: {image_path} (attempt {attempt})")
        if not wait_for_file_stability(image_path):
            if attempt < MAX_QUEUE_RETRIES:
                print(f"File not ready yet, requeueing: {image_path}")
                file_queue.put((image_path, attempt + 1))
            else:
                print(f"Max retries reached, skipping file: {image_path}")
            return
        
        print(f"Processing image: {image_path}")
        with Image.open(image_path) as img:
            # Preserve EXIF and other metadata if available
            exif = img.info.get('exif', None)  # Explicitly handle None case
            icc_profile = img.info.get('icc_profile', None)  # Same for ICC profile
            
            # Convert to RGB if needed, preserving alpha channel if present
            if img.mode == 'RGBA':
                img = img
            elif img.mode != 'RGB':
                img = img.convert('RGB')

            # Check if resizing is needed
            width, height = img.size
            if width <= MAX_WIDTH and height <= MAX_HEIGHT:
                # If it's a HEIC file, we still need to convert it to JPEG
                if image_path.lower().endswith(('.heic', '.heif')):
                    new_path = os.path.splitext(image_path)[0] + '.jpg'
                    save_kwargs = {'quality': 100, 'optimize': False}
                    if exif is not None:  # Only include exif if it exists
                        save_kwargs['exif'] = exif
                    if icc_profile is not None:  # Only include ICC if it exists
                        save_kwargs['icc_profile'] = icc_profile
                    img.save(new_path, 'JPEG', **save_kwargs)
                    try:
                        os.remove(image_path)  # Remove original HEIC file
                        print(f"Converted HEIC to JPEG: {new_path}")
                    except Exception as e:
                        print(f"Error removing original HEIC file: {str(e)}")
                else:
                    print(f"No resize needed for {image_path} ({width}x{height})")
                return

            print(f"Current image size: {width}x{height}")

            # Calculate memory requirements and use progressive loading if needed
            memory_estimate = (width * height * 3) / (1024 * 1024)  # In MB
            if memory_estimate > 500:  # If image might use more than 500MB
                print(f"Large image detected ({memory_estimate:.2f}MB), using progressive loading")
                img.draft('RGB', (width//2, height//2))

            # Resize while maintaining aspect ratio
            img.thumbnail((MAX_WIDTH, MAX_HEIGHT), Image.LANCZOS)
            new_width, new_height = img.size
            print(f"Resizing image from {width}x{height} to {new_width}x{new_height}")

            # Handle HEIC/HEIF files specially
            file_ext = image_path.lower().split('.')[-1]
            if file_ext in ['heic', 'heif']:
                new_path = os.path.splitext(image_path)[0] + '.jpg'
                save_kwargs = {'quality': 100, 'optimize': False}
                if exif is not None:  # Only include exif if it exists
                    save_kwargs['exif'] = exif
                if icc_profile is not None:  # Only include ICC if it exists
                    save_kwargs['icc_profile'] = icc_profile
                
                img.save(new_path, 'JPEG', **save_kwargs)
                try:
                    os.remove(image_path)  # Remove original HEIC file
                    print(f"Converted and resized HEIC to JPEG: {new_path}")
                except Exception as e:
                    print(f"Error removing original HEIC file: {str(e)}")
            else:
                # For other formats, use original settings
                save_kwargs = {
                    'jpg': {'format': 'JPEG', 'quality': 100, 'optimize': False},
                    'jpeg': {'format': 'JPEG', 'quality': 100, 'optimize': False},
                    'png': {'format': 'PNG', 'optimize': False},
                    'tiff': {'format': 'TIFF', 'compression': None}
                }

                kwargs = save_kwargs.get(file_ext, {})
                if exif is not None:  # Only include exif if it exists
                    kwargs['exif'] = exif
                if icc_profile is not None:  # Only include ICC if it exists
                    kwargs['icc_profile'] = icc_profile
                
                img.save(image_path, **kwargs)
                print(f"Successfully saved resized image: {image_path}")

    except Exception as e:
        print(f"Error processing {image_path}: {str(e)}")
        if attempt < MAX_QUEUE_RETRIES:
            print(f"Requeueing due to error: {image_path}")
            file_queue.put((image_path, attempt + 1))

def is_image_file(filename):
    """Check if a file is an image based on its extension"""
    return filename.lower().endswith(('.png', '.jpg', '.jpeg', '.tiff', '.bmp', '.gif', '.heic', '.heif'))

def scan_existing_files():
    """Scan for existing images in the PICTURES_FOLDER and process them"""
    print(f"\nScanning existing files in {PICTURES_FOLDER}")
    count = 0
    for root, _, files in os.walk(PICTURES_FOLDER):
        for filename in files:
            if is_image_file(filename):
                file_path = os.path.join(root, filename)
                print(f"Found existing image: {file_path}")
                file_queue.put((file_path, 1))  # Start with attempt 1
                count += 1
    print(f"Initial scan completed. Found {count} images to process.\n")

def process_new_files():
    """ Processes new files from the queue """
    while True:
        try:
            item = file_queue.get()  # Get file from queue
            if isinstance(item, tuple):
                image_path, attempt = item
            else:
                image_path, attempt = item, 1  # Handle old-style queue items
            resize_image(image_path, attempt)
            file_queue.task_done()
        except Exception as e:
            print(f"Error in process_new_files: {str(e)}")

class ImageWatcher(FileSystemEventHandler):
    """ Watches for new image files and adds them to the queue """
    def on_created(self, event):
        if not event.is_directory and is_image_file(event.src_path):
            print(f"\nNew image detected: {event.src_path}")
            file_queue.put((event.src_path, 1))  # Start with attempt 1

def start_watching():
    """ Watches the PICTURES_FOLDER for new files """
    # First scan existing files
    scan_existing_files()
    
    # Then start watching for new files
    observer = Observer()
    event_handler = ImageWatcher()
    observer.schedule(event_handler, PICTURES_FOLDER, recursive=True)
    observer.start()
    print(f"Watching for new images in {PICTURES_FOLDER}")

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("\nStopping image watcher...")
        observer.stop()
    observer.join()

if __name__ == "__main__":
    # Start the worker thread that processes images in the queue
    worker_thread = threading.Thread(target=process_new_files, daemon=True)
    worker_thread.start()

    start_watching()

Save with CTRL+o and exit with CTRL+x.

Make the file executable with

chmod +x resize_images.py

Add a system file to launch automatically

If you want the script to run in the background, create a service file to start it automatically at boot.

Run the following command to create the service file:

sudo nano /etc/systemd/system/image-resizer.service

Then, add this content, replacing your-username with your actual user name (Raspberry Pi OS no longer creates a default pi account):

[Unit]
Description=Automatic Image Resizer for Digital Picture Frame
After=network.target

[Service]
ExecStart=/home/your-username/venv_picframe/bin/python3 /home/your-username/resize_images.py
WorkingDirectory=/home/your-username/
Restart=always
User=your-username
Group=your-username

[Install]
WantedBy=multi-user.target

After saving the file, reload systems:

sudo systemctl daemon-reload

Then, enable the service so it starts at boot:

sudo systemctl enable image-resizer.service

To start it immediately without rebooting:

sudo systemctl start image-resizer.service

To check if it’s running:

sudo systemctl status image-resizer.service

Conclusion

With this script running in the background, your images will always be resized correctly for your digital picture frame. This is especially recommended if you’re using a Pi Zero, but it’s a good idea regardless.

Just be sure to keep your original files.

And think about that last point before you start: if you shrink everything to the size of the screen you have now, the photographs will not be ready for the bigger screen you buy next. That is the strongest argument for letting the frame do the scaling in memory — which is what picframe3 does — rather than editing your library.