Introduction: Smart Picture Frame (Raspberry Pi 4)

This Instructable will walk through the process in which I built a smart picture frame with the Raspberry Pi 4. While I did not get it working on the intended screen in time, I will be detailing how to finish the project in depth. This gave me a relatively simple project I knew I was capable of doing, but because it was being programmed from the ground up, it really helped me dig deeper into Python and the Raspberry Pi's architecture.

Supplies

  1. Raspberry Pi 4
  2. 10.1 inch LCD Display for the Raspberry Pi 3/4/5
  3. 1x USB-to-USB-C cable
  4. Stands, screws, and screwdriver as included with LCD display

Step 1: Hardware Setup

This project take both minimal hardware and minimal hardware setup, which is part of the beauty of it, for me. The Raspberry Pi is simply mounted onto the back of the screen with 4 screws; There are four orange, circular slots you'll see on the center of the back of the screen, where the Pi can be screwed in. From there, highlighted #1, the provided HDMI-to-Mini-HDMI adapter, and highlighted #2, the provided USB-to-MicroUSB adapter, connect the Raspberry Pi to the screen, and provide the screen power from the Raspberry Pi itself. Meaning, all you need to connect beyond that is highlighted #3, the USB-C power cable to the Raspberry Pi itself.

Notably, once the Raspberry Pi is lined up with the mounting slots on the back, it will be very clear where the mentioned adapters plug in; there will be both a USB and a Micro-USB port, neatly adjacent to one another, as well as both an HDMI and a Mini-HDMI port. The given adapters will fit the two ports perfectly.

Step 2: Install IDE/Python

For this project, I used Visual Studio Code, and at some points, VSCodium, the Pi's built-in alternative to Visual Studio. However, any development environment program, Thonny, VSCode, SublimeText, etc. can be used. If you are developing this on the Raspberry Pi itself, like I will be in these instructions, Python will already be installed on the device. If Python is not already installed, you can install it using the following:

sudo apt update
sudo apt install python3-pip

Step 3: Install Necessary Dependencies

To get our picture frame to work, we need some Python libraries that will allow us to create a GUI application, display an image inside that application, and animate transitions between the images.

In order to install a Python dependency, we can use the following command. This example is in the case of PyQt6, the primary library we need:

pip install pyqt6

OR (to install the package globally, if necessary):

sudo apt install python3-pyqt6
Conveniently, for the example, PyQt is the only library we need to install. Everything else we're using is built into Python. Import the libraries using the following, at the very top of a new Python file. Mine will be named init.py.
from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel, QGraphicsOpacityEffect
from PyQt6.QtCore import QPropertyAnimation, QEasingCurve, Qt, QTimer, QBasicTimer
from PyQt6.QtGui import QPixmap

from pathlib import Path
import sys, threading, time

Step 4: Build the GUI Class

PyQt, the library that serves as the engine for our program, works by creating a "class", which will specify some variables and functionality that we need to be set automatically whenever a new window is made. Let's define the GUI class, which upon being initiated with the __init__ function, will set some basic information, such as the size, title, and properties of the window.


Upon initiation, we also will create a "label", which is a container in PyQt6 that houses visual elements, and we will set that label's pixmap to an image of ours. A pixmap is simply a fancy term for the containers that labels use to house images. For a label to show an image, we must set that label's pixmap.

class GUI(QMainWindow):

# The __init__ function is necessary to the class, and
# is called when a GUI window is created.

def __init__(self, directory_to_scan: str = None):

# This function covers the basics, such as setting the
# window title, creating a label, which houses the image,
# setting the label's formatting, and lastly expanding to fullscreen.

super().__init__()
self.setWindowTitle("Sesame")

if directory_to_scan:
global src_dir
src_dir = Path(directory_to_scan)

self.pix = None
self.label = QLabel()
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.label.setScaledContents(True)
self.setCentralWidget(self.label)
self.showFullScreen()

# Next, it pulls all workable images from the source directory.

for f in src_dir.iterdir():
if f.is_file() and f.name.endswith((".png", ".jpg", ".jpeg")):
# Appended into our list of working files is 'px', which is a ready-to-go container with an image in it
path = Path(src_dir) / f.name
px = QPixmap(str(path))

Step 5: Add Class Functionality

Added onto the GUI class, underneath the __init__ function, we'll add a function to run a loop of our images (slideshow), detect when the escape key is pressed to exit the program, and to automatically resize the application to the full size of our working display.

# Initiates slideshow and displays our images in PyQt window.

def run_loop(self):
# Starts by setting the first image, and configuring opacity, so the initial image is invisible

self.pix = loaded_images[0]
scaled = self.pix.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
self.label.setPixmap(scaled)

current_opacity = 0
self.opacity_effect = QGraphicsOpacityEffect(self.label)
self.opacity_effect.setOpacity(0)
self.label.setGraphicsEffect(self.opacity_effect)

while True:
for i in loaded_images:
self.pix = i
scaled = self.pix.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
self.label.setPixmap(scaled)

for i in range(fade_interval):

# Reduces current_opacity by an interval determined by fade_tick_duration,
# then sets the opacity to that current_opacity value. Convenient way to
# avoid breaking into tweening libraries to shift opacity value.

current_opacity += (1 / fade_interval)
self.opacity_effect.setOpacity(current_opacity)
time.sleep(0.001)
time.sleep(display_duration_s)

for i in range(fade_interval):

# Reduces current_opacity by an interval determined by fade_tick_duration,
# then sets the opacity to that current_opacity value. Convenient way to
# avoid breaking into tweening libraries to shift opacity value.

current_opacity -= (1 / fade_interval)
self.opacity_effect.setOpacity(current_opacity)
time.sleep(0.001)
time.sleep(next_image_duration_s)
# Fortunately, PyQt6 comes with a built-in keyboard listener.
# Exit key is set to 16777216, which is the ID for the Escape key.
# Closes the program if exit key is pressed, returning a success code of 0.

def keyPressEvent(self, event):
if event.key() == exit_shortcut:
app.exit(0)

# Automatically scales up/down image if window size changes.
# Shouldn't come up in regular use; This is more of a failsafe to prevent the
# image display from breaking in the event the window size unexpectedly changes.

def resizeEvent(self, event):
if self.pix:
scaled = self.pix.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
self.label.setPixmap(scaled)
super().resizeEvent(event)

Added

Step 6: Running the Program

At the end of the file, outside of the GUI class we just finished building, we'll instantiate the GUI class by making a new variable with it called window; we'll employ something called threading onto our slideshow, which simply prevents the program from crashing when we exit using the escape key, due to running multiple processes on one thread at a time; and lastly, we'll initiate the slideshow by running the slideshow thread we created, which calls our main run_loop function.

# Instantiates the GUI class, creates a thread to loop the slideshow in the
# background, sets the thread's 'daemon' property to True (which lets us close
# the program even while the slideshow is actively running), and starts the
# loop with start() and sys.exit().

window = GUI()
slideshow = threading.Thread(target=window.run_loop)
slideshow.daemon = True
slideshow.start()
sys.exit(app.exec())

Your final product, code and running program, will look as the following on the Raspberry Pi, when ran from your IDE, or when ran from the terminal using "python3 init.py".

#####################################################
# #
# Sesame - Raspberry Pi Digital Picture Frame #
# init.py #
# #
# Automatic Media-Fed Digital Picture Frame #
# Plug in Raspberry Pi and insert a USB drive #
# with photos on it to start a smart slideshow. #
# #
# Written by Zachary Hartin #
# #
#####################################################

from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel, QGraphicsOpacityEffect
from PyQt6.QtCore import QPropertyAnimation, QEasingCurve, Qt, QTimer, QBasicTimer
from PyQt6.QtGui import QPixmap

from pathlib import Path
import sys, threading, time

app = QApplication(sys.argv)
exit_shortcut: int = 16777216
loaded_images = []
src_dir: str = Path.cwd()

fade_interval = 3000
display_duration_s = 5
next_image_duration_s = 1

# To create a window in PyQt, we need to utilize QMainWindow
# inside of a class, which will also house our functions.

class GUI(QMainWindow):

# The __init__ function is necessary to the class, and
# is called when a GUI window is created.

def __init__(self, directory_to_scan: str = None):

# This function covers the basics, such as setting the
# window title, creating a label, which houses the image,
# setting the label's formatting, and lastly expanding to fullscreen.

super().__init__()
self.setWindowTitle("Sesame")

if directory_to_scan:
global src_dir
src_dir = Path(directory_to_scan)

self.pix = None
self.label = QLabel()
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.label.setScaledContents(True)
self.setCentralWidget(self.label)
self.showFullScreen()

# Next, it pulls all workable images from the source directory.

for f in src_dir.iterdir():
if f.is_file() and f.name.endswith((".png", ".jpg", ".jpeg")):
path = Path(src_dir) / f.name
px = QPixmap(str(path))
loaded_images.append(px)

# Initiates slideshow and displays our images in PyQt window.

def run_loop(self):
# Starts by setting the first image, and configuring opacity, so the initial image is invisible

self.pix = loaded_images[0]
scaled = self.pix.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
self.label.setPixmap(scaled)

current_opacity = 0
self.opacity_effect = QGraphicsOpacityEffect(self.label)
self.opacity_effect.setOpacity(0)
self.label.setGraphicsEffect(self.opacity_effect)

while True:
for i in loaded_images:
self.pix = i
scaled = self.pix.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
self.label.setPixmap(scaled)

for i in range(fade_interval):

# Reduces current_opacity by an interval determined by fade_tick_duration,
# then sets the opacity to that current_opacity value. Convenient way to
# avoid breaking into tweening libraries to shift opacity value.

current_opacity += (1 / fade_interval)
self.opacity_effect.setOpacity(current_opacity)
time.sleep(0.001)
time.sleep(display_duration_s)

for i in range(fade_interval):

# Reduces current_opacity by an interval determined by fade_tick_duration,
# then sets the opacity to that current_opacity value. Convenient way to
# avoid breaking into tweening libraries to shift opacity value.

current_opacity -= (1 / fade_interval)
self.opacity_effect.setOpacity(current_opacity)
time.sleep(0.001)
time.sleep(next_image_duration_s)
# Fortunately, PyQt6 comes with a built-in keyboard listener.
# Exit key is set to 16777216, which is the ID for the Escape key.
# Closes the program if exit key is pressed, returning a success code of 0.

def keyPressEvent(self, event):
if event.key() == exit_shortcut:
app.exit(0)

# Automatically scales up/down image if window size changes.
# Shouldn't come up in regular use; This is more of a failsafe to prevent the
# image display from breaking in the event the window size unexpectedly changes.

def resizeEvent(self, event):
if self.pix:
scaled = self.pix.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
self.label.setPixmap(scaled)
super().resizeEvent(event)

# Instantiates the GUI class, creates a thread to loop the slideshow in the
# background, sets the thread's 'daemon' property to True (which lets us close
# the program even while the slideshow is actively running), and starts the
# loop with start() and sys.exit().

window = GUI()
slideshow = threading.Thread(target=window.run_loop)
slideshow.daemon = True
slideshow.start()
sys.exit(app.exec())