Pi-Powered Infinite Art Frame (Kotlin on Raspberry Pi)¶
Objective: Turn a Raspberry Pi into a living art installation β a headless JVM process renders generative art frames continuously, shifts palettes by time of day, and exposes a tiny HTTP API for remote control. The display refreshes from disk; no X server required in the render process.
What You're Building¶
- A headless Kotlin/JVM process renders PNG frames using Java2D offscreen rendering
- Palettes and parameters shift slowly over time (per-minute tick, time-of-day phase)
- Frames are written to a fixed output path; a lightweight viewer on the Pi reads from disk
- A minimal Ktor HTTP server accepts live parameter changes (palette, seed, speed)
- Runs as a systemd service; logs are capped; old frames are cleaned automatically
- GPU upgrade path (OpenGL ES on Pi via JOGL) documented as future work
Architecture¶
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Timer (coroutine, per-minute tick) β
β + TimeOfDay phase (hour β palette zone) β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β update Params
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Renderer (Java2D / BufferedImage offscreen) β
β Generative algorithm (e.g., flow field, fractal) β
β Write frame β /tmp/artframe/latest.png β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β file write
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Display (feh / fim / browser kiosk mode) β
β Reads latest.png; refreshes on file change β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Ktor HTTP server (port 8080) β
β GET /status β current params JSON β
β POST /palette/{name} β change palette β
β POST /seed/{n} β reseed β
β POST /speed/{f} β change tick interval β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Prerequisites¶
| Requirement | Notes |
|---|---|
| Raspberry Pi 4 (2 GB+ RAM) | Pi 3 works but is slower; Pi 5 is ideal |
| JDK 17 (ARM64) | sudo apt install openjdk-17-jdk |
| Kotlin 1.9+ | Deployed as a fat JAR β no Kotlin install needed on Pi |
| Gradle 8.x | Build on dev machine; deploy JAR to Pi |
feh (image viewer) | sudo apt install feh β for file-based display refresh |
Build on your dev machine; deploy the fat JAR to the Pi. Do not try to run Gradle on the Pi β it's slow and unnecessary.
./gradlew shadowJar # produces build/libs/artframe-all.jar
scp build/libs/artframe-all.jar pi@raspberrypi.local:/opt/artframe/
Project Layout¶
pi-artframe/
βββ build.gradle.kts
βββ settings.gradle.kts
βββ src/main/kotlin/artframe/
βββ Main.kt # entry point: start renderer + HTTP server
βββ Params.kt # shared mutable state (thread-safe)
βββ TimePhase.kt # time-of-day β palette zone
βββ Renderer.kt # Java2D offscreen render + PNG write
βββ Algorithms.kt # generative algorithm(s)
βββ Palette.kt # named color palettes
βββ Api.kt # Ktor route definitions
Core Concepts¶
- Headless Java2D:
BufferedImage+Graphics2Dworks without a display. No X11, no GLFW, no framebuffer access needed. Setjava.awt.headless=true. - Fixed output path: render to
/tmp/artframe/latest.png. Atomic write via temp-file rename prevents the viewer reading a half-written PNG. - Time-of-day palette: divide 24 hours into phases (dawn, day, dusk, night). Each phase has a target palette. Interpolate between them over a 30-minute transition window.
- Per-minute tick: a coroutine wakes every N seconds (configurable), updates params (nudge seed, advance palette interpolation), and triggers a render.
- Ktor server: embedded Ktor with
embeddedServer(Netty, ...)β no external server needed. ~3 MB dependency. - Atomic frame write: write to
/tmp/artframe/next.png, thenFiles.move(..., ATOMIC_MOVE)tolatest.png. Prevents torn reads by the viewer. - Disk usage control: keep only the last N frames in a rotating directory; delete older ones in the cleanup coroutine.
Implementation¶
Step 1 β build.gradle.kts¶
plugins {
kotlin("jvm") version "1.9.23"
id("com.github.johnrengelman.shadow") version "8.1.1"
application
}
repositories { mavenCentral() }
dependencies {
implementation(kotlin("stdlib"))
implementation("io.ktor:ktor-server-netty:2.3.11")
implementation("io.ktor:ktor-server-core:2.3.11")
implementation("io.ktor:ktor-server-content-negotiation:2.3.11")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
application { mainClass.set("artframe.MainKt") }
Step 2 β Shared params (thread-safe)¶
// Params.kt
package artframe
import java.util.concurrent.atomic.AtomicReference
import kotlinx.serialization.Serializable
@Serializable
data class ArtParams(
val seed: Long = 42L,
val palette: String = "dawn",
val speedSeconds: Int = 10,
val paletteMix: Float = 0.0f, // 0.0 = current palette, 1.0 = next
)
// Thread-safe holder β renderer and HTTP handler share this reference
val PARAMS = AtomicReference(ArtParams())
Step 3 β Time-of-day palette phase¶
// TimePhase.kt
package artframe
import java.time.LocalTime
enum class Phase { DAWN, DAY, DUSK, NIGHT }
fun currentPhase(): Phase {
val h = LocalTime.now().hour
return when {
h in 5..8 -> Phase.DAWN
h in 9..17 -> Phase.DAY
h in 18..21 -> Phase.DUSK
else -> Phase.NIGHT
}
}
fun phaseToDefaultPalette(p: Phase) = when(p) {
Phase.DAWN -> "dawn"
Phase.DAY -> "daylight"
Phase.DUSK -> "ember"
Phase.NIGHT -> "midnight"
}
Step 4 β Renderer (Java2D offscreen)¶
// Renderer.kt
package artframe
import artframe.Algorithms.drawFlowField
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import javax.imageio.ImageIO
object Renderer {
private const val W = 1920; private const val H = 1080
private val outDir = File("/tmp/artframe").also { it.mkdirs() }
fun render(params: ArtParams) {
// headless = no display required
System.setProperty("java.awt.headless", "true")
val img = BufferedImage(W, H, BufferedImage.TYPE_INT_RGB)
val g = img.createGraphics().apply {
setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
}
drawFlowField(g, W, H, params) // your algorithm here
g.dispose()
// Atomic write: temp β rename
val tmp = File(outDir, "next.png")
val dest = File(outDir, "latest.png")
ImageIO.write(img, "PNG", tmp)
Files.move(tmp.toPath(), dest.toPath(), StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING)
}
}
Step 5 β Flow field algorithm (example)¶
// Algorithms.kt β partial snippet
package artframe
import artframe.Palette.colorForPalette
import java.awt.BasicStroke
import java.awt.Graphics2D
import kotlin.math.*
import kotlin.random.Random
object Algorithms {
fun drawFlowField(g: Graphics2D, w: Int, h: Int, params: ArtParams) {
val rng = Random(params.seed)
val pal = colorForPalette(params.palette)
g.color = pal.background
g.fillRect(0, 0, w, h)
g.stroke = BasicStroke(1.2f)
val step = 12; val len = 40; val angScale = 0.004f
for (sy in 0 until h step step) {
for (sx in 0 until w step step) {
var x = sx.toFloat(); var y = sy.toFloat()
val alpha = (rng.nextFloat() * 180 + 40).toInt()
g.color = java.awt.Color(pal.strokes[rng.nextInt(pal.strokes.size)].rgb
.let { java.awt.Color(it) }.let {
java.awt.Color(it.red, it.green, it.blue, alpha)
})
for (s in 0..len) {
val angle = sin(x * angScale + params.seed * 0.001f) *
cos(y * angScale) * PI.toFloat() * 2f
val nx = x + cos(angle) * step * 0.4f
val ny = y + sin(angle) * step * 0.4f
g.drawLine(x.toInt(), y.toInt(), nx.toInt(), ny.toInt())
x = nx; y = ny
if (x < 0 || x >= w || y < 0 || y >= h) break
}
}
}
}
}
Step 6 β Ktor HTTP API¶
// Api.kt
package artframe
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun startApi() = embeddedServer(Netty, port = 8080) {
routing {
get("/status") {
call.respondText(Json.encodeToString(PARAMS.get()),
contentType = io.ktor.http.ContentType.Application.Json)
}
post("/palette/{name}") {
val name = call.parameters["name"] ?: "dawn"
PARAMS.updateAndGet { it.copy(palette = name) }
call.respondText("palette set to $name")
}
post("/seed/{n}") {
val n = call.parameters["n"]?.toLongOrNull() ?: return@post
PARAMS.updateAndGet { it.copy(seed = n) }
call.respondText("seed set to $n")
}
post("/speed/{f}") {
val f = call.parameters["f"]?.toIntOrNull() ?: return@post
PARAMS.updateAndGet { it.copy(speedSeconds = f.coerceIn(1, 3600)) }
call.respondText("speed set to ${f}s")
}
}
}.start(wait = false)
Step 7 β Main entry point + render loop¶
// Main.kt
package artframe
import kotlinx.coroutines.*
fun main() {
println("Pi Art Frame startingβ¦")
startApi()
runBlocking {
// Phase-sync: update palette once per minute based on time of day
launch {
while (true) {
val phase = currentPhase()
val target = phaseToDefaultPalette(phase)
PARAMS.updateAndGet { it.copy(palette = target) }
delay(60_000)
}
}
// Render loop
while (true) {
val params = PARAMS.get()
try {
Renderer.render(params)
// Drift seed slightly each frame for organic variation
PARAMS.updateAndGet { it.copy(seed = it.seed + 1) }
} catch (e: Exception) {
System.err.println("Render error: ${e.message}")
}
delay(params.speedSeconds * 1000L)
}
}
}
Display Strategy¶
Option A β feh (simplest, Linux/Pi)¶
# Install:
sudo apt install feh
# Kiosk mode: full-screen, reload on file change every 5s
feh --fullscreen --reload 5 /tmp/artframe/latest.png &
Add to ~/.config/autostart/artframe-viewer.desktop for login autostart.
Option B β Browser kiosk with local HTTP server¶
Serve /tmp/artframe/ via a tiny Python server + auto-refreshing HTML:
# Serve frames:
cd /tmp/artframe && python3 -m http.server 9090 &
# Chromium kiosk:
chromium-browser --kiosk --noerrdialogs \
"http://localhost:9090/frame.html"
<!-- /tmp/artframe/frame.html -->
<html><body style="margin:0;background:#000">
<img id="f" src="latest.png" style="width:100vw;height:100vh;object-fit:contain">
<script>setInterval(()=>{document.getElementById('f').src='latest.png?'+Date.now()},5000)</script>
</body></html>
systemd Service¶
# /etc/systemd/system/artframe.service
[Unit]
Description=Pi Infinite Art Frame
After=network.target
[Service]
User=pi
WorkingDirectory=/opt/artframe
ExecStart=/usr/bin/java -Xmx512m -Djava.awt.headless=true -jar artframe-all.jar
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable artframe
sudo systemctl start artframe
sudo journalctl -u artframe -f
Disk usage control (cron)¶
# /etc/cron.hourly/artframe-cleanup
#!/bin/bash
# Keep only the 48 most recent frames; delete older ones
ls -t /tmp/artframe/frame_*.png 2>/dev/null | tail -n +49 | xargs rm -f
Controls / Interaction¶
# Remote control via HTTP:
curl http://raspberrypi.local:8080/status
curl -X POST http://raspberrypi.local:8080/palette/midnight
curl -X POST http://raspberrypi.local:8080/seed/42
curl -X POST http://raspberrypi.local:8080/speed/30 # render every 30s
Performance Notes¶
- Java2D at 1920Γ1080 with a flow-field algorithm renders in 1β3 seconds on Pi 4. Acceptable for a 10β30 second update cycle.
- Set
-Xmx512mto bound heap usage. Java2D uses off-heap for image buffers β monitor RSS, not just heap. - If render time exceeds the tick interval, renders will queue. Use a semaphore to skip frames rather than queue them.
- GPU upgrade path: replace Java2D with JOGL (OpenGL ES 2.0 on Pi) for sub-second renders. More complex; see JOGL + EGL headless setup for ARM Linux.
Troubleshooting¶
java.awt.HeadlessException: add -Djava.awt.headless=true to the JVM flags in the systemd service (already shown above).
/tmp/artframe/latest.png not updating: check journalctl -u artframe for render errors; verify JVM has write permission to /tmp/artframe/.
feh shows stale image: ensure --reload N is set; alternatively use inotifywait: inotifywait -m /tmp/artframe/latest.png -e close_write | xargs -I{} feh --reload 0 latest.png.
Ktor port 8080 in use: change in Api.kt; confirm with ss -tlnp | grep 8080.
High memory usage: reduce render resolution (e.g. 1280Γ720) or reduce algorithm complexity. Check with systemctl status artframe β Memory line.
See also
- Pi-Based Sample Library Server β another always-on Pi service with an HTTP API; same deployment pattern (systemd, Pi 4, local network)
- RKE2 on Raspberry Pi β run art frame as a Kubernetes Pod if you have a Pi cluster
- Recursive Cathedral Generator β export cathedral frames into this display pipeline
- Cellular Automata Organism Garden β render garden frames on the Pi art display
- MIDI-Driven Particle Nebula β send nebula screenshots to the art frame for a live MIDI-to-wall pipeline