Building a Digital Picture Frame: From a Recycled Laptop to Raspberry Pi
Overview
Long before Raspberry Pis became my go-to answer for small dedicated displays, I built a digital picture frame out of an old laptop.
And I mean out of the laptop.
I removed the display, motherboard, storage, wireless card, and whatever other components I needed, figured out how to arrange everything behind a frame, installed Lubuntu, and turned what had once been a laptop into a dedicated digital photo display.
It wasn’t elegant inside.
There were laptop parts stuffed into a picture frame.
But it worked.
The machine booted directly into Linux, loaded a slideshow, and displayed photographs without needing someone to sit in front of it and operate it like a computer. When the pictures needed to change, I could connect to the system, copy new files into its image directory, and let the display take care of the rest.
That project taught me something I’ve carried into a lot of projects since:
A computer doesn’t have to look like a computer.
Sometimes it is a calendar.
Sometimes it is a kiosk.
Sometimes it is signage.
And sometimes it is just a picture frame hanging on a wall.
In this project, we’re going to recreate that original idea and then build the version I would use today: a Raspberry Pi-powered network-connected digital picture frame that boots automatically, displays photographs full-screen, and can be updated remotely.
What We’re Building
The finished system will:
- Boot automatically when powered on.
- Connect to the network.
- Display photographs full-screen.
- Rotate through images automatically.
- Require no keyboard or mouse during normal use.
- Allow photographs to be uploaded remotely.
- Automatically discover new images.
- Run after a reboot without manual intervention.
Conceptually, the system is very simple:
Network
|
|
+------v------+
| Raspberry Pi |
+------+------+
|
+----------+-----------+
| |
Photo Directory Local Web App
~/picture-frame/ Python Server
photos/ |
|
Chromium Kiosk
|
v
HDMI Display
The Raspberry Pi is just the newest implementation of the idea.
The architecture itself hasn’t changed much from my original laptop version.
The Original Build
My first digital picture frame started with an old laptop.
Instead of leaving the laptop intact, I removed the components and mounted them behind the frame.
The basic design looked something like this:
+----------------------------------------+
| |
| LCD PANEL |
| |
| Family Photograph |
| |
+----------------------------------------+
Behind the frame
+----------------------------------------+
| Laptop motherboard |
| Storage |
| Wi-Fi |
| LCD controller / original connections |
| Power |
+----------------------------------------+
The laptop wasn’t being used as a laptop anymore.
The keyboard was unnecessary.
The trackpad was unnecessary.
The case was unnecessary.
What I really needed was:
Processor
Memory
Storage
Display
Network
Linux
Everything else was packaging.
That mindset becomes particularly useful when working with embedded computers such as the Raspberry Pi.
Why Lubuntu?
For the original build, I used Lubuntu.
Lubuntu provided a lightweight Linux desktop without requiring the resources of a heavier desktop environment. On recycled laptop hardware, that mattered.
Linux also gave me complete control over the system.
I could decide:
- What happened during boot.
- Where photographs were stored.
- How the slideshow started.
- How long images stayed on screen.
- How new pictures were transferred.
- Whether users ever saw the desktop.
A directory could contain the photographs:
/home/frame/photos/
A script could launch the slideshow:
#!/bin/bash
feh \
--fullscreen \
--auto-zoom \
--hide-pointer \
--slideshow-delay 10 \
--randomize \
/home/frame/photos
Then the desktop environment could automatically execute that script when the user logged in.
The result was effectively an appliance.
Power it on.
Linux boots.
The slideshow starts.
That’s it.
The Modern Version
Today I would build the same project using a Raspberry Pi.
A Pi is smaller, uses less power, has no unnecessary laptop hardware, can mount almost anywhere behind a display, and is extremely well suited to running one dedicated application.
Instead of using a traditional Linux image viewer, we’re going to make the modern version slightly more flexible.
We’ll use:
- Raspberry Pi OS
- Python
- HTML
- JavaScript
- Chromium kiosk mode
- SSH
systemd
The browser will display our local photo application, while Python provides the list of photographs currently stored on the Pi.
This gives us a foundation that can later become much more than a picture frame.
Hardware
You’ll need:
| Component | Purpose |
|---|---|
| Raspberry Pi | Runs the display |
| microSD card | Operating system and photographs |
| Raspberry Pi power supply | Powers the Pi |
| HDMI cable | Connects Pi to display |
| Monitor or television | Displays photographs |
| Wi-Fi or Ethernet | Remote management |
| Computer | Initial setup and photo uploads |
A Raspberry Pi 4 or Pi 5 is an excellent choice, although this project isn’t computationally demanding.
The Raspberry Pi can eventually be mounted behind the monitor or inside a custom frame.
Step 1 — Install Raspberry Pi OS
Use Raspberry Pi Imager to install:
Raspberry Pi OS (64-bit)
During OS customisation, configure:
Hostname: picture-frame
Username: frame
Password: <your password>
Wi-Fi:
SSID: <your wireless network>
Password: <your wireless password>
Remote Access:
Enable SSH
Use your own username if you prefer. Every example in this project assumes:
frame
If you use something different, adjust the paths accordingly.
Important: Current versions of Raspberry Pi OS should have Wi-Fi configured through Raspberry Pi Imager or the operating system’s networking tools. Older tutorials that tell you to drop a
wpa_supplicant.conffile into the boot partition are outdated for current Raspberry Pi OS releases.
Insert the microSD card into the Pi, connect HDMI, and power it on. if your going to go headless, find the IP address withing your DHCP server, or run nmap on the network.
Step 2 — Connect to the Pi
From another computer:
ssh frame@picture-frame.local
If hostname resolution isn’t available on your network, determine the Pi’s IP address and connect directly:
ssh frame@192.168.1.50
Replace the example IP address with the actual address assigned to your Pi.
Step 3 — Update the System
Start by updating the package lists:
sudo apt update
Install available upgrades:
sudo apt upgrade -y
Then reboot:
sudo reboot
After the system returns, reconnect over SSH.
Step 4 — Create the Project Directory
Create a directory for the project:
mkdir -p ~/picture-frame/photos
Move into it:
cd ~/picture-frame
Our project will eventually look like this:
picture-frame/
├── app.py
├── templates/
│ └── index.html
└── photos/
├── vacation.jpg
├── family.jpg
└── dog.jpg
Create the template directory:
mkdir templates
Step 5 — Install Python Flask
We’ll use Flask to provide a tiny local web application.
Install it:
sudo apt install python3-flask -y
Flask allows Python to provide the webpage while dynamically determining which photographs exist in the photo directory.
That means we don’t have to rewrite our HTML every time a picture changes.
Step 6 — Create the Photo Server
Create:
nano ~/picture-frame/app.py
Add:
from flask import Flask, render_template, send_from_directory
from pathlib import Path
app = Flask(__name__)
PHOTO_DIR = Path.home() / "picture-frame" / "photos"
ALLOWED_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
}
def get_photos():
"""Return supported image files from the photo directory."""
if not PHOTO_DIR.exists():
return []
return sorted(
file.name
for file in PHOTO_DIR.iterdir()
if file.is_file()
and file.suffix.lower() in ALLOWED_EXTENSIONS
)
@app.route("/")
def index():
return render_template("index.html", photos=get_photos())
@app.route("/photos/<path:filename>")
def photo(filename):
return send_from_directory(PHOTO_DIR, filename)
if __name__ == "__main__":
app.run(
host="127.0.0.1",
port=8080,
debug=False,
)
Save the file.
What This Code Does
This line defines where photographs live:
PHOTO_DIR = Path.home() / "picture-frame" / "photos"
The application scans the directory and accepts:
.jpg
.jpeg
.png
.gif
.webp
When Chromium requests the homepage, Flask creates the page using the photographs it finds.
Individual images are delivered through:
/photos/<filename>
The application listens only on:
127.0.0.1:8080
That is intentional.
Chromium and Flask run on the same Raspberry Pi, so there’s no reason to expose the web application to the entire network.
Step 7 — Build the Slideshow
Create the HTML template:
nano ~/picture-frame/templates/index.html
Add:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>Picture Frame</title>
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: black;
}
#slideshow {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
#slideshow img {
width: 100%;
height: 100%;
object-fit: contain;
}
#message {
color: white;
font-family: sans-serif;
text-align: center;
}
</style>
</head>
<body>
<div id="slideshow">
{% if photos %}
<img
id="photo"
src="/photos/{{ photos[0] }}"
alt="Digital Picture Frame"
>
{% else %}
<div id="message">
No photographs have been uploaded yet.
</div>
{% endif %}
</div>
{% if photos %}
<script>
const photos = {{ photos | tojson }};
let currentPhoto = 0;
const image = document.getElementById("photo");
function nextPhoto() {
currentPhoto =
(currentPhoto + 1) % photos.length;
image.src =
"/photos/" + encodeURIComponent(
photos[currentPhoto]
);
}
setInterval(nextPhoto, 10000);
</script>
{% endif %}
</body>
</html>
Save the file.
How the Slideshow Works
Flask generates a JavaScript array containing the available photographs.
For example:
const photos = [
"family.jpg",
"vacation.jpg",
"christmas.jpg"
];
JavaScript then advances through the array:
currentPhoto =
(currentPhoto + 1) % photos.length;
Every ten seconds:
setInterval(nextPhoto, 10000);
the browser switches to the next image.
Want 30 seconds?
Change:
10000
to:
30000
JavaScript measures time in milliseconds:
1000 = 1 second
5000 = 5 seconds
10000 = 10 seconds
30000 = 30 seconds
60000 = 1 minute
Step 8 — Add Some Pictures
From another Linux or macOS machine:
scp vacation.jpg frame@picture-frame.local:/home/frame/picture-frame/photos/
Copy several files:
scp *.jpg frame@picture-frame.local:/home/frame/picture-frame/photos/
From PowerShell on Windows with the OpenSSH client installed, the same scp syntax can be used:
scp *.jpg frame@picture-frame.local:/home/frame/picture-frame/photos/
You could also use an SFTP client.
The important part is simply getting files into:
/home/frame/picture-frame/photos/
Check them:
ls -lh ~/picture-frame/photos
Example:
family.jpg
vacation.jpg
christmas.jpg
dog.webp
Step 9 — Test the Application
Run:
cd ~/picture-frame
Then:
python3 app.py
You should see Flask indicate that it is listening on:
http://127.0.0.1:8080
From the Raspberry Pi desktop, open:
http://127.0.0.1:8080
You should see your photograph.
After ten seconds, the next photograph should appear.
Stop the test with:
Ctrl+C
Step 10 — Run the Photo Server Automatically
We don’t want to manually run:
python3 app.py
every time the Pi starts.
This is exactly what systemd is for.
Create a service:
sudo nano /etc/systemd/system/picture-frame.service
Add:
[Unit]
Description=Digital Picture Frame Web Application
After=network.target
[Service]
Type=simple
User=frame
WorkingDirectory=/home/frame/picture-frame
ExecStart=/usr/bin/python3 /home/frame/picture-frame/app.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
If your username isn’t frame, change:
User=frame
and the /home/frame/ paths.
Save the file.
Reload systemd:
sudo systemctl daemon-reload
Enable the service:
sudo systemctl enable picture-frame.service
Start it:
sudo systemctl start picture-frame.service
Check the status:
systemctl status picture-frame.service
You want to see:
Active: active (running)
Useful Service Commands
Start:
sudo systemctl start picture-frame
Stop:
sudo systemctl stop picture-frame
Restart:
sudo systemctl restart picture-frame
Status:
systemctl status picture-frame
View logs:
journalctl -u picture-frame
Watch the logs live:
journalctl -u picture-frame -f
These commands are especially useful when troubleshooting the frame remotely.
Step 11 — Launch Chromium in Kiosk Mode
Now we need the graphical portion of the system to automatically open the photo application.
Current Raspberry Pi OS uses a desktop configuration that can automatically launch programs after login.
Create or edit:
nano ~/.config/labwc/autostart
Add:
chromium http://127.0.0.1:8080 \
--kiosk \
--noerrdialogs \
--disable-infobars \
--no-first-run \
--start-maximized &
This tells Chromium to:
- Open our local photo application.
- Use kiosk mode.
- Remove normal browser controls.
- Suppress unnecessary dialogs.
- Start maximised.
The result is a display that looks like an appliance rather than a desktop computer.
Step 12 — Reboot and Test
Reboot:
sudo reboot
The intended startup sequence is now:
Power On
|
v
Raspberry Pi OS
|
+----------------------+
| |
v v
systemd Desktop Login
| |
v v
Flask Server Chromium
| |
+----------+-----------+
|
v
http://127.0.0.1:8080
|
v
PHOTO FRAME
Once the system finishes booting, Chromium should open full-screen and begin displaying photographs.
No keyboard required.
No mouse required.
No browser interaction required.
Step 13 — Updating the Frame Remotely
This was one of my favourite parts of the original project.
There was no need to remove storage or physically work on the machine.
Just copy another photograph into the directory.
For example:
scp new-photo.jpg frame@picture-frame.local:/home/frame/picture-frame/photos/
Then remotely restart Chromium or reload the page.
The easiest approach is simply to have the page occasionally refresh its photo list automatically.
Let’s add that now.
Automatically Discover New Photos
Our current page receives its photo list when the page initially loads.
If we copy another photograph into the directory, Flask knows it exists — but the currently loaded browser page does not.
We can solve that by periodically refreshing the page.
Add this to the JavaScript in index.html:
setTimeout(() => {
window.location.reload();
}, 300000);
300000 milliseconds equals five minutes.
The complete bottom portion becomes:
const photos = {{ photos | tojson }};
let currentPhoto = 0;
const image = document.getElementById("photo");
function nextPhoto() {
currentPhoto =
(currentPhoto + 1) % photos.length;
image.src =
"/photos/" + encodeURIComponent(
photos[currentPhoto]
);
}
setInterval(nextPhoto, 10000);
setTimeout(() => {
window.location.reload();
}, 300000);
Now the frame:
Changes image every 10 seconds
+
Reloads image list every 5 minutes
Upload a photograph:
scp beach.jpg frame@picture-frame.local:/home/frame/picture-frame/photos/
Within five minutes, the frame discovers it automatically.
That’s much closer to the behaviour I wanted from the original project.
Removing Pictures
SSH into the frame:
ssh frame@picture-frame.local
View photographs:
ls ~/picture-frame/photos
Remove one:
rm ~/picture-frame/photos/old-photo.jpg
The next browser refresh removes it from the slideshow.
Uploading an Entire Folder
Suppose your computer contains:
FamilyPhotos/
You can copy the contents:
scp FamilyPhotos/* frame@picture-frame.local:/home/frame/picture-frame/photos/
Or recursively copy directories if you later modify the application to support them:
scp -r FamilyPhotos frame@picture-frame.local:/home/frame/picture-frame/
For the version developed in this guide, keeping all slideshow images directly inside:
photos/
keeps things simple.
Randomising the Photographs
The original Linux version could use:
feh --randomize
We can add the same idea to our modern application.
Inside app.py, add:
import random
Then modify get_photos():
def get_photos():
"""Return supported image files in random order."""
if not PHOTO_DIR.exists():
return []
photos = [
file.name
for file in PHOTO_DIR.iterdir()
if file.is_file()
and file.suffix.lower() in ALLOWED_EXTENSIONS
]
random.shuffle(photos)
return photos
Every time the browser refreshes, the slideshow order changes.
Optional — Fit or Fill the Screen
Our CSS currently uses:
object-fit: contain;
This displays the entire image and preserves its aspect ratio.
Depending on the image, black bars may appear.
If you want photographs to completely fill the screen, change it to:
object-fit: cover;
The difference is:
| Setting | Behaviour |
|---|---|
contain | Entire photograph remains visible |
cover | Entire screen is filled; some cropping may occur |
For a digital frame, I usually prefer:
object-fit: contain;
because I would rather see the complete photograph.
Optional — Add a Fade Transition
We can make the picture changes look much nicer.
Modify the image CSS:
#slideshow img {
width: 100%;
height: 100%;
object-fit: contain;
opacity: 1;
transition: opacity 1s ease-in-out;
}
Then change nextPhoto():
function nextPhoto() {
image.style.opacity = 0;
setTimeout(() => {
currentPhoto =
(currentPhoto + 1) % photos.length;
image.src =
"/photos/" + encodeURIComponent(
photos[currentPhoto]
);
image.style.opacity = 1;
}, 1000);
}
Now the current photograph fades out before the next one appears.
It’s a small change that makes the project feel much more polished.
Optional — Use SSH Keys
Because this is intended to become an appliance, I prefer SSH keys over repeatedly entering a password.
On the management computer:
ssh-keygen
Then copy the public key:
ssh-copy-id frame@picture-frame.local
Test:
ssh frame@picture-frame.local
Once SSH key authentication is confirmed, the Pi can be managed much more conveniently.
SSH keys can also be configured during initial installation using Raspberry Pi Imager.
Security Considerations
It would be easy to look at a digital picture frame and think security doesn’t matter.
But this is still:
A Linux computer
+
Connected to your network
+
Running services
Treat it like one.
Keep Raspberry Pi OS Updated
Periodically run:
sudo apt update
sudo apt upgrade
Don’t Expose SSH to the Internet
SSH should normally be accessible only from your trusted internal network.
Do not create a router port-forward directly to:
TCP 22
just so you can upload photographs remotely.
If remote access from outside the home is needed, use a secure VPN or another appropriately secured remote-access solution instead.
Use SSH Keys
Key-based authentication is preferable for systems that will be remotely administered regularly.
Keep the Web Application Local
Notice that Flask is configured as:
host="127.0.0.1"
rather than:
host="0.0.0.0"
Our browser lives on the same Pi.
There is no reason for other computers to connect directly to the picture-frame web application.
That reduces unnecessary network exposure.
Troubleshooting
The Service Doesn’t Start
Check:
systemctl status picture-frame
Then:
journalctl -u picture-frame
Confirm Python exists:
which python3
Expected:
/usr/bin/python3
Confirm the application exists:
ls -l ~/picture-frame/app.py
Chromium Opens but the Page Doesn’t Load
Verify the Flask application:
systemctl status picture-frame
Test locally:
curl http://127.0.0.1:8080
If HTML is returned, Flask is working.
The problem is likely Chromium or the desktop autostart configuration.
No Pictures Appear
Check the directory:
ls -lh ~/picture-frame/photos
Confirm the files have supported extensions:
.jpg
.jpeg
.png
.gif
.webp
Linux filenames are case-sensitive, and our Python code converts the extension to lowercase before checking it, so files such as:
PHOTO.JPG
will still work.
New Pictures Don’t Appear
Wait for the automatic browser reload.
Or reload the page manually.
You can also restart the graphical session, but that shouldn’t normally be necessary.
The Screen Goes Blank After a While
If the display works initially and later powers off, investigate desktop power management and monitor power-saving settings.
Depending on the display and operating-system version, screen blanking can come from several places:
Linux power management
Desktop environment
Monitor sleep settings
HDMI behaviour
This is particularly important for signage and kiosk projects because those systems are expected to remain visible without user interaction.
Could We Have Just Used feh?
Absolutely.
And I still like that version.
A minimal picture-frame script might simply be:
#!/bin/bash
feh \
--fullscreen \
--auto-zoom \
--hide-pointer \
--randomize \
--slideshow-delay 10 \
~/picture-frame/photos
That is wonderfully simple.
For a machine whose only job will ever be displaying local photographs, that may be all you need.
I chose Chromium and Python for the modern build because it gives us somewhere to go next.
The project can evolve without replacing the underlying architecture.
Where This Gets Interesting
At this point, we’ve technically finished the digital picture frame.
But look at what we’ve actually built.
We have:
Raspberry Pi
|
+--- Linux
|
+--- Network Access
|
+--- Python
|
+--- Local Web Application
|
+--- Chromium Kiosk
|
+--- HDMI Display
Pictures are just one possible data source.
Replace the application and suddenly this becomes:
Digital Signage
or:
Google Calendar Display
or:
Weather Dashboard
or:
Home Assistant Dashboard
or:
Network Monitoring Display
or:
Security Operations Dashboard
The physical device barely changes.
The application does.
The Project Came Full Circle
What I like most about revisiting this project is that the technology changed far more than the idea did.
The first version involved taking apart an old laptop.
I removed the hardware I needed, stuffed the components into a frame, installed Lubuntu, wrote some scripts, created a folder for the pictures, and made Linux handle the rest.
It was a little ridiculous.
It was also exactly the kind of project that got me interested in making computers do things they weren’t necessarily packaged to do.
Today I can accomplish the same thing with a computer barely larger than a deck of cards.
Then:
Old Laptop
↓
Disassembly
↓
Laptop Parts
↓
Lubuntu
↓
Shell Scripts
↓
Picture Frame
Today:
Raspberry Pi
↓
Raspberry Pi OS
↓
Python
↓
Chromium Kiosk
↓
Picture Frame
The hardware got smaller.
The software got better.
The idea stayed the same.
Build the computer around the job instead of forcing the job around the computer.
And that’s why the Raspberry Pi version isn’t really a different project.
It’s version 2.0 of something I built years ago.
Commands Used
| Command | Purpose |
|---|---|
sudo apt update | Refresh package information |
sudo apt upgrade -y | Install available updates |
mkdir -p ~/picture-frame/photos | Create project and photo directories |
sudo apt install python3-flask -y | Install Flask |
python3 app.py | Test the photo application |
scp | Transfer photographs remotely |
ssh | Remotely administer the Pi |
systemctl | Manage the picture-frame service |
journalctl | Examine service logs |
curl | Test the local web application |
Skills Practiced
This seemingly simple project touches several useful technical skills:
- Linux administration
- Raspberry Pi deployment
- SSH
- Secure remote management
- Python
- Flask
- HTML
- CSS
- JavaScript
systemd- File management
- Service troubleshooting
- Kiosk systems
- Embedded computing
- Network-connected displays
That’s one reason projects like this are useful.
The finished product might just show family photos, but building it teaches considerably more.
Ideas for Version 3
There are plenty of ways this project could evolve.
We could add:
- A web-based upload interface.
- Password-protected administration.
- Multiple photo albums.
- Date-based playlists.
- Scheduled display hours.
- Automatic screen sleep overnight.
- Photo captions.
- Weather information.
- Calendar events.
- Remote management.
- Automatic synchronisation from network storage.
- A central server controlling multiple displays.
- Monitoring and health reporting.
And once several displays exist, this stops being a digital picture frame project and starts becoming a digital signage platform.
Which may be where we go next.
Key Takeaways
Main Lesson
A Raspberry Pi can turn an ordinary display into a dedicated appliance, but the project is really an example of a larger idea: combine inexpensive hardware, Linux, automation, and networking to make a computer perform one job extremely well.
Important Commands
sudo apt update
sudo apt upgrade -y
mkdir -p ~/picture-frame/photos
sudo apt install python3-flask -y
ssh frame@picture-frame.local
scp *.jpg frame@picture-frame.local:/home/frame/picture-frame/photos/
sudo systemctl enable picture-frame
sudo systemctl start picture-frame
systemctl status picture-frame
journalctl -u picture-frame
Skills Practiced
- Raspberry Pi configuration
- Linux administration
- Python web applications
- HTML and JavaScript
systemdservice management- SSH
- Secure file transfer
- Kiosk-mode displays
- Automation
Defensive Considerations
Even a digital picture frame is a network-connected Linux computer.
Keep it patched, restrict unnecessary services, avoid exposing management interfaces directly to the Internet, use secure authentication, and expose applications only where network access is actually required.
Sometimes the best security decision is the simplest one:
If a service doesn’t need to be accessible from the network, don’t put it on the network.
