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:

ComponentPurpose
Raspberry PiRuns the display
microSD cardOperating system and photographs
Raspberry Pi power supplyPowers the Pi
HDMI cableConnects Pi to display
Monitor or televisionDisplays photographs
Wi-Fi or EthernetRemote management
ComputerInitial 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.conf file 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:

SettingBehaviour
containEntire photograph remains visible
coverEntire 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

CommandPurpose
sudo apt updateRefresh package information
sudo apt upgrade -yInstall available updates
mkdir -p ~/picture-frame/photosCreate project and photo directories
sudo apt install python3-flask -yInstall Flask
python3 app.pyTest the photo application
scpTransfer photographs remotely
sshRemotely administer the Pi
systemctlManage the picture-frame service
journalctlExamine service logs
curlTest 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
  • systemd service 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.

Similar Posts