A custom camera application for Raspberry Pi with a 3.5" TFT touchscreen display. This project demonstrates real-time image capture, gallery browsing, settings control, and touch input handling on embedded Linux.
- Quick Overview
- Hardware
- Software Architecture
- Installation
- How It Works
- Artifacts & System Structure
- What I Learned
This is a fully functional camera application running as a systemd daemon on Raspberry Pi OS. Users can:
- Capture photos using a physical shutter button or on-screen button
- View gallery of previously captured images
- Adjust settings like JPEG quality and resolution
- Touch interface for seamless navigation between views
Images are stored in:
/data/photos
Filenames use timestamp format:
YYYY-MM-DD_HH-MM-SS.jpg
Example:
2026-02-14_12-03-08.jpg
Current hardware configuration:
Compute
- Raspberry Pi 3B+
Camera
- OV5647 (Arducam / Raspberry Pi Camera v1 compatible)
Display
- 3.5" SPI TFT
- Controller: ILI9486
- Resolution: 480×320
- Device:
/dev/fb1
Input
- Physical shutter button (GPIO)
Storage
- MicroSD card
Power
- Currently direct power
- Future: battery + power controller
Application (app.py)
├── Event Loop
│ ├── Touch Events (from /dev/input)
│ └── Shutter Events (from GPIO)
├── View Controller
│ ├── CameraView - Live preview & capture UI
│ ├── GalleryView - Browse saved photos
│ └── SettingsView - Configure quality & resolution
├── Device Drivers
│ ├── Framebuffer - Direct pixel access to TFT
│ └── Camera - rpicam-still binary wrapper
└── UI Components
├── Buttons - Touch-sensitive UI elements
├── Overlays - Status displays
└── Font Rendering - Text on display
Event-Driven Architecture: The application loops continuously, polling the event queue for input from touchscreen or shutter button, then dispatching to the appropriate view handler.
View Controller Pattern: Similar to MVC, the controller manages transitions between CameraView, GalleryView, and SettingsView. Each view is responsible for its own rendering and input handling.
Memory-Mapped Framebuffer: Direct access to /dev/fb1 using NumPy arrays for fast pixel operations. RGB565 16-bit color format matches the display's native format.
The installation process is automated across three phases:
sudo bash setup/00-base.sh- Updates package manager and installs dependencies
- Disables unused services to free resources
- Installs Python libraries:
python3-pil,python3-rpi.gpio,picamera2
sudo bash setup/01-boot.sh- Loads the Device Tree Overlay (
tft35a-overlay.dtb) - Creates
/dev/fb1for framebuffer access - Creates
/dev/input/eventXfor touchscreen input - Modifies
/boot/firmware/config.txtto auto-load overlay on boot
Why Device Tree Overlays?
Device Tree Overlays tell the Linux kernel which hardware drivers to load and how to wire GPIO pins. Instead of hardcoding hardware in the kernel, overlays make configurations flexible and replaceable.
sudo bash setup/02-services.sh- Copies application files to
/opt/camera/ - Copies configuration to
/etc/camera/camera.conf - Registers systemd service at
/etc/systemd/system/camera.service - Enables auto-start on boot
Starting the service:
sudo systemctl start camera.service
sudo systemctl status camera.service
sudo journalctl -f -u camera.service # View logs- Bootloader loads Device Tree Overlay → patches
/dev/spi0 - Kernel matches device tree entries to drivers:
- ILI9486 @ CS0 → framebuffer driver →
/dev/fb1 - ADS7846 @ CS1 → input driver →
/dev/input/eventX
- ILI9486 @ CS0 → framebuffer driver →
- Systemd starts
camera.service, which launchesapp.py - Application initializes framebuffer, camera, and input handlers
- Event loop begins accepting input and rendering
# Initialization
fb = Framebuffer(fb_path="/dev/fb1", width=480, height=320)
camera = Camera(PHOTO_DIR="/data/photos")
controller = ViewController(current_view=CameraView, devices={fb, camera})
# Event Loop
while True:
# Poll for events
if event := event_queue.get(timeout=0.016): # ~60 FPS
# Route to current view
controller.handle_input(event)
# Current view renders itself
current_view.render(fb)View.render()
├── Clear framebuffer to background color
├── Draw Images
│ ├── PIL Image → RGB → Resize
│ └── Convert to RGB565 → Memory-map to /dev/fb1
├── Draw UI Elements
│ ├── Buttons (PNG icons scaled)
│ └── Text overlays
└── Flush display (automatic via mmap)
Touchscreen: /dev/input/eventX generates raw touch events → parsed to (x, y) coordinates → routed to button collision detection
Shutter Button: GPIO interrupt on GPIO pin → debounced → enqueued as BUTTON_PRESS event
After installation, the following artifacts are created:
/opt/camera/app.py- Main event loop and initialization/opt/camera/framebuffer.py- Direct framebuffer access using mmap and NumPy/opt/camera/camera.py- Wrapper aroundrpicam-stillbinary/opt/camera/views/- View controllers (CameraView, GalleryView, SettingsView)/opt/camera/input/- Input processing (touch events, GPIO events)/opt/camera/assets/- PNG icons and UI resources
/etc/camera/camera.conf- Configuration parameters (framebuffer path, photo directory, GPIO pins)/etc/systemd/system/camera.service- Service definition (auto-restart, standard output to journal)
/dev/fb1- Framebuffer for TFT (480×320 RGB565)/dev/input/eventX- Touch input device (ADS7846)
/data/photos/- Directory where captured JPEG images are saved with ISO 8601 timestamps
Initially, I thought you'd need to write complex kernel code to enable hardware. Instead, Device Tree Overlays provide a declarative way to describe hardware and wire GPIO pins. The .dts source file is compiled to .dtb binary, and Linux loads it at boot to dynamically patch the device tree. This is far cleaner than hardcoded kernel drivers.
Key insight: Linux prefers GPIO → kernel driver mapping over userspace bit-banging. Using spidev for custom protocols is fine, but for standard devices (like ILI9486 TFT or ADS7846 touchscreen), let the kernel driver handle the heavy lifting.
Instead of making syscalls for every pixel, opening /dev/fb1 as a file and memory-mapping it lets you treat it like a NumPy array. Writing to the array directly updates the display. Combined with RGB565 (2 bytes per pixel) matching the hardware's native format, this achieves smooth rendering without bottlenecks.
Key insight: Framebuffer drivers abstract away SPI protocol details. You just write pixels; the driver handles DMA and SPI transfers to the display.
A main event loop that polls an input queue is far simpler than threading or interrupts scattered throughout the code. Each view handler is stateless and responds to discrete input events.
Key insight: For embedded UI applications, a single event loop with a queue beats multi-threaded complexity. The event loop sleeps when idle, consuming minimal CPU.
Using systemd's Type=simple, Restart=always, and StandardOutput=journal means:
- The service auto-restarts on crash
- Logs go to
journalctl, visible without SSH - No custom daemonization code needed
This is far superior to manual nohup or & backgrounding.
Using PIL to load JPEG, resize to display dimensions (480×320), and convert color formats is surprisingly fast on Pi. Combined with NumPy for bulk pixel operations, it's efficient enough for a camera app.
Key insight: Don't reach for C/C++ immediately. Python with PIL and NumPy handles image processing efficiently on embedded hardware.
A physical button generates bounces (multiple edges in quick succession). Even a 10ms debounce sleep in the shutter thread prevents duplicate captures.
Key insight: Hardware buttons aren't instantaneous; always debounce in software or hardware.
The camera saves to a temporary file, then atomically renames it. If the process crashes mid-write, the temp file is left behind, but the gallery never shows a corrupted JPEG. Syncing the photo directory's file descriptor ensures metadata is written.
Key insight: On Linux, os.rename() is atomic at the filesystem level. Use temp files + rename for safe writes, especially on unreliable power (embedded devices).
Raw touch coordinates from the ADS7846 chip map directly to screen coordinates after the Device Tree wires GPIO pins. Linux's evdev input driver handles the protocol; userspace just reads (x, y) tuples from /dev/input/eventX.
Key insight: Let the kernel input subsystem handle protocol parsing. Your app just reads high-level events, not raw SPI bytes.
Storing PNG icons in the app directory and loading them with PIL at runtime avoids compiling assets into the binary. This keeps the codebase modular.
Key insight: Embedded Linux gives you a real filesystem. Use it for resources instead of over-engineering.
Standard output/stderr redirected to journalctl means you can view logs remotely without SSH'ing into the Pi. Including print() statements throughout the code provides visibility.
Key insight: For headless embedded systems, structured logging (even basic print() statements) is critical for debugging over the network.
Required Python packages:
PIL (Pillow)
RPi.GPIO
picamera2
NumPy
Required system tools:
rpicam-still(camera tool)- Device tree compiler (dtc)
Camera not found:
journalctl -u camera.service -bLook for "Found framebuffer" message. If missing, check /sys/class/graphics/ for available framebuffers.
Display not updating:
Verify /dev/fb1 exists and is writable. Device Tree Overlay may not have loaded.
Touch input not working:
Check /dev/input/ for event devices. Verify ADS7846 driver is loaded: lsmod | grep ads7846
- Add wireless image transfer
- Implement custom camera effects/filters
- Add night mode with IR LED
- Web-based remote preview
- Video recording support
This project is provided as-is for educational and personal use.
- Check status of service
systemctl status camera.service
- Check logs
journalctl -r -u camera.service -b




