Daily stylized art from public domain museum collections.
Fetches CC0 landscapes from the Metropolitan Museum of Art and Art Institute of Chicago — or landscape photos from Unsplash — applies AdaIN neural style transfer with curated style references, and serves the results via a free Cloudflare Worker API.
Scheduled runs use the CC0 museum sources, so everything published to the API is CC0 and no API key sits on the critical path. Unsplash remains available for manual runs (--source unsplash, or the source input on the Generate workflow); its licence is permissive but not CC0.
A new landscape is generated every day overnight (4 AM UTC). Grab it and set it in one line:
macOS
curl -sfo /tmp/bauhaus.jpg https://bauhaus.cascadiacollections.workers.dev/api/today
osascript -e 'tell application "System Events" to tell every desktop to set picture to POSIX file "/tmp/bauhaus.jpg"'Windows (PowerShell)
Invoke-WebRequest https://bauhaus.cascadiacollections.workers.dev/api/today -OutFile "$env:TEMP\bauhaus.jpg"
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class W { [DllImport("user32.dll")] public static extern int SystemParametersInfo(int a,int b,string c,int d); }'
[W]::SystemParametersInfo(0x0014,0,"$env:TEMP\bauhaus.jpg",0x01)Linux (KDE Plasma)
curl -sfo /tmp/bauhaus.jpg https://bauhaus.cascadiacollections.workers.dev/api/today
dbus-send --session --dest=org.kde.plasmashell --type=method_call /PlasmaShell org.kde.PlasmaShell.evaluateScript "string:
var d = desktops(); for (var i = 0; i < d.length; i++) { d[i].wallpaperPlugin = 'org.kde.image';
d[i].currentConfigGroup = ['Wallpaper','org.kde.image','General']; d[i].writeConfig('Image','file:///tmp/bauhaus.jpg'); }"Linux (GNOME)
curl -sfo /tmp/bauhaus.jpg https://bauhaus.cascadiacollections.workers.dev/api/today
gsettings set org.gnome.desktop.background picture-uri "file:///tmp/bauhaus.jpg"
gsettings set org.gnome.desktop.background picture-uri-dark "file:///tmp/bauhaus.jpg"Automate it with a cron job, Task Scheduler, or systemd timer to get fresh art on your desktop every morning.
GitHub Actions (daily, 4 AM UTC / 8 PM PT; retried at 10 AM UTC)
1. Fetch CC0 landscape from Met/AIC (falling back to the other collection
if one comes up empty; or a photo from Unsplash on demand)
2. Pick curated style ref (Monet, Hokusai, Cezanne, Turner, ...)
3. AdaIN style transfer (CPU, ~5s at native resolution)
4. Score the result (heuristics + NIMA) and record it in the metadata
5. Generate AVIF + WebP variants for smaller files and faster loads
6. Upload original + stylized (JPEG/AVIF/WebP) + metadata to Cloudflare R2
|
CF Worker API <-- R2 bucket
GET /api/today -> stylized image (AVIF/WebP/JPEG via content negotiation)
GET /api/today.json -> metadata
GET /api/:date -> archive
Runs daily via GitHub Actions. Total cost: $0/month.
| Component | Monthly cost |
|---|---|
| Cloudflare R2 (10 GB free) | $0 |
| Cloudflare Workers (100k req/day free) | $0 |
| GitHub Actions (public repo) | $0 |
Base URL: https://bauhaus.cascadiacollections.workers.dev
| Endpoint | Returns |
|---|---|
GET /api/today |
Today's stylized image (content-negotiated: AVIF → WebP → JPEG) |
GET /api/today.json |
Today's metadata (title, artist, source, license, variants) |
GET /api/today.manifest.json |
Variant manifest (srcset / responsive helper) |
GET /api/YYYY-MM-DD |
Stylized image for a specific date (content-negotiated) |
GET /api/YYYY-MM-DD/original |
Original unstylized image |
GET /api/YYYY-MM-DD.json |
Metadata for a specific date |
GET /api/YYYY-MM-DD.manifest.json |
Variant manifest for a specific date |
GET /api/YYYY-MM-DD.json.sig |
Detached PGP signature over that date's metadata JSON (404 when signing is off) |
GET /api/archive |
The dates that have published artwork, newest first (paginated) |
GET /api/health |
Publish freshness for uptime monitoring — 200 when current, 503 when stale |
POST /api/vitals |
Ingest Web Vitals RUM (Analytics Engine) |
POST /api/err |
Ingest JS error RUM (Analytics Engine) |
All GET endpoints also support HEAD — returns the same response headers (including Content-Type, ETag, and Cache-Control) with no body. This enables browser <link rel="preload"> validation and CDN cache priming.
| Endpoint pattern | Cache-Control |
|---|---|
/api/today* |
public, max-age=300, s-maxage=3600, stale-while-revalidate=604800 — short browser TTL since "today" rolls over daily; the edge TTL stays below the 24h publish interval so a PoP cannot serve yesterday's artwork past the next run |
/api/archive |
Same as /api/today* — the index gains an entry every morning, so it cannot be immutable |
/api/YYYY-MM-DD* |
public, max-age=31536000, s-maxage=31536000, immutable — safe because publishing is write-once: the pipeline refuses to rewrite a date unless --overwrite is passed. The check runs twice, once before the fetch so a collision costs nothing and once immediately before the first upload, which is the one that closes the race |
Every other date endpoint requires knowing the date already. /api/archive
answers which dates exist, so a gallery or a "random past day" consumer has
something to enumerate:
curl -s https://bauhaus.cascadiacollections.workers.dev/api/archive?limit=3{
"dates": ["2026-08-30", "2026-08-29", "2026-08-28"],
"count": 3,
"total": 184,
"next": "/api/archive?limit=3&before=2026-08-28"
}| Query parameter | Values | Description |
|---|---|---|
limit |
1–1000 (default 100) | Dates per page |
before |
YYYY-MM-DD |
Return only dates earlier than this one |
Paging is by date rather than an opaque cursor: dates are immutable, so a
next link means the same thing tomorrow as it does today and can be
constructed by hand. next is absent on the last page. An invalid limit or
before is a 400 rather than a silently corrected page. A truncated: true
field, which should not occur for decades, means the listing hit its internal
round-trip cap and total counts only what was walked.
Fetch each date's metadata or image with the regular endpoints:
BASE=https://bauhaus.cascadiacollections.workers.dev
for date in $(curl -s "$BASE/api/archive?limit=5" | jq -r '.dates[]'); do
curl -s "$BASE/api/$date.json" | jq -r '"\(.date) \(.title) — \(.artist)"'
doneUse the manifest endpoint or content-negotiation directly with a <picture> element for optimal LCP performance:
<picture>
<source type="image/avif" srcset="https://bauhaus.cascadiacollections.workers.dev/api/today">
<source type="image/webp" srcset="https://bauhaus.cascadiacollections.workers.dev/api/today">
<img
src="https://bauhaus.cascadiacollections.workers.dev/api/today"
alt="Daily stylized art"
fetchpriority="high"
loading="eager"
>
</picture>For preload hints in <head>:
<link
rel="preload"
as="image"
href="https://bauhaus.cascadiacollections.workers.dev/api/today"
imagesrcset="https://bauhaus.cascadiacollections.workers.dev/api/today"
type="image/avif"
>Image endpoints (/api/today, /api/YYYY-MM-DD) support automatic format selection.
The Worker inspects the Accept header and serves the best available pre-generated variant (AVIF → WebP → JPEG).
If the preferred format is missing, it falls back to JPEG.
| Query parameter | Values | Description |
|---|---|---|
format |
auto (default), jpeg, avif, webp |
Explicit format override |
progressive |
true |
Serve progressive JPEG variant |
strip |
true |
Serve EXIF-stripped (privacy-safe) variant |
All image responses include a Vary: Accept header for correct caching.
The Worker uses Accept header content negotiation for the base image endpoints. If the client sends Accept: image/avif, the AVIF variant is returned (falling back to JPEG if unavailable). Use the format query parameter above to override it explicitly; an unrecognised value is rejected with 400 rather than silently negotiated.
Two first-party RUM endpoints persist to Workers Analytics Engine. Only requests from allowed origins (configured via ALLOWED_ORIGINS) are accepted. The Beacon API is used on the client side — both endpoints only accept POST.
Wire format:
{
"name": "LCP",
"value": 1234.5,
"id": "v3-1234567890-1234",
"rating": "good",
"navigationType": "navigate",
"url": "https://kevintcoughlin.com/"
}name accepts LCP, INP, CLS, FCP, or TTFB. rating is good, needs-improvement, or poor. Persisted to the web_vitals Analytics Engine dataset.
Wire format:
{
"message": "Uncaught TypeError: ...",
"source": "https://kevintcoughlin.com/bauhaus.js",
"lineno": 42,
"colno": 7,
"stack": "..."
}stack is optional and truncated to 1 KB by the client before sending. Persisted to the web_errors Analytics Engine dataset.
| Condition | Response |
|---|---|
| Allowed origin, valid body | 204 No Content |
Disallowed or missing Origin |
403 Forbidden |
| Non-POST method | 405 Method Not Allowed |
| Body > 4 KB | 413 Payload Too Large |
OPTIONS preflight (allowed origin) |
204 with CORS headers |
CORS response includes Access-Control-Allow-Origin: <echoed>, Access-Control-Allow-Methods: POST, Access-Control-Allow-Headers: content-type.
Query stored data via the Cloudflare dashboard SQL editor or:
wrangler analytics-engine sql 'SELECT * FROM web_vitals LIMIT 10'
wrangler analytics-engine sql 'SELECT * FROM web_errors LIMIT 10'Requires mise (or manually install uv, Python 3.14+, Node.js 24+, and just).
For a ready-to-use dev environment, open this repo in VS Code and choose "Reopen in Container" — the included .devcontainer/ setup provisions Node, Python 3.14, uv, and just.
# Install dependencies
just setup
# Download AdaIN + NIMA model weights (~107 MB)
just download-models
# Re-fetch the curated CC0 style references (already checked in)
just download-styles
# Run tests
just test
# Generate locally (no R2 upload)
just generate
# Generate benchmark metrics for parity tracking
just benchmark-generate --max-size 1536
# Enforce local benchmark thresholds
just benchmark-gate
# Options (extra args forwarded to src/main.py)
just generate --source met # Metropolitan Museum (default)
just generate --source artic # Art Institute of Chicago
just generate --source unsplash # Unsplash — needs UNSPLASH_ACCESS_KEY
just generate --alpha 0.5 # subtle style (0.0-1.0)
just generate --any-subject # disable landscape filter
just generate --max-size 1536 # higher processing resolution
just generate --no-score # skip NIMA scoring (heuristics still run)
just generate --overwrite # republish a date already in R2
just generate --skip-if-published # exit 0 instead of failing if the date exists
# List all available recipes
justjust docker-build
just docker-run
# Podman-compatible equivalents
podman build -t bauhaus .
podman run --rm -v "$PWD/output:/app/output" --env-file .env bauhaus --dry-runUse a rootless Podman setup and a writable bind mount for output/ if you want to keep generated files on the host.
mise install # provision Python, Node, uv, just
just setup-all # install project deps (uv sync + npm ci)
just worker-dev # start local dev server
just worker-check # typecheck| Variable | Description |
|---|---|
R2_ENDPOINT |
Cloudflare R2 S3-compatible endpoint |
R2_ACCESS_KEY_ID |
R2 access key |
R2_SECRET_ACCESS_KEY |
R2 secret key |
R2_BUCKET |
Bucket name (default: bauhaus) |
STYLE_MODE |
curated (rotate shipped styles) or random (fetch second CC0 painting) |
UNSPLASH_ACCESS_KEY |
Unsplash API access key. Only needed for --source unsplash; the CC0 museum sources require no credentials. |
LANDSCAPES_ONLY |
true (default) bias toward landscapes/seascapes, false for any subject |
MEMORY_PROFILE |
balanced (default) or low-memory. low-memory caps MAX_SIZE at 1024 and disables variant generation by default to fit constrained CPU/RAM runners. |
GENERATE_VARIANTS |
Generate AVIF and WebP variants alongside JPEG (default: true, or false in low-memory) |
MAX_SIZE |
Max processing resolution in pixels (default: 1280). Lower values are better for the current CPU-only/free-tier runner; low-memory caps this at 1024. |
METRICS_OUT |
Write run timing/resource metrics JSON to this path (also --metrics-out). Used by the benchmark workflow. |
METRICS_LABEL |
Optional label recorded in the metrics JSON to annotate benchmark runs (also --metrics-label). |
NTFY_TOPIC |
ntfy.sh topic for success/failure notifications. Set as a repository secret; notification steps are skipped when unset. |
- Keep secrets in GitHub Actions secrets / local
.envfiles only; never print them in logs. - The production workflow uses
R2_*andUNSPLASH_ACCESS_KEYfrom secret storage, not hard-coded values. - For local Podman runs, pass env vars via
--env-file .envor a secret manager rather than embedding them in shell history.
Each day's metadata/<date>.json can be published alongside a detached
signature at metadata/<date>.json.sig, letting consumers verify the metadata
came from this pipeline. Signing is currently dormant — the workflow skips
the Import GPG key step whenever GPG_PRIVATE_KEY is unset, and
sign_metadata() returns None rather than failing, so runs succeed silently
without a signature.
To turn it on, set three repository secrets:
| Secret | What it is |
|---|---|
GPG_PRIVATE_KEY |
ASCII-armoured private key. Gates the workflow's import step, and on its own is enough to enable signing. |
GPG_KEY_ID |
Key ID or fingerprint, passed to gpg --local-user. Optional when the imported key is the only secret key present. |
GPG_PASSPHRASE |
Passphrase, if the key has one. Omit for a passphrase-less key. |
Setting any one of the three turns signing on. The signature covers the canonical JSON bytes that are uploaded, byte for byte.
Verify a published day:
BASE=https://bauhaus.cascadiacollections.workers.dev
curl -sf "$BASE/api/2026-01-02.json" -o metadata.json
curl -sf "$BASE/api/2026-01-02.json.sig" -o metadata.json.sig
gpg --verify metadata.json.sig metadata.jsonGenerating and exporting a signing key:
# Create a signing-only key (no expiry prompt in batch mode)
gpg --quick-generate-key "Bauhaus <you@example.com>" ed25519 sign never
# Fingerprint → GPG_KEY_ID
gpg --list-secret-keys --keyid-format=long
# Private key → GPG_PRIVATE_KEY (paste the whole armoured block)
gpg --armor --export-secret-keys <KEY_ID>
# Public key — publish this so consumers can verify
gpg --armor --export <KEY_ID>The signature covers the metadata JSON only, not the image bytes.
Every published image is scored and the result is recorded under aesthetic in
that date's metadata JSON. Two independent signals are stored side by side:
"aesthetic": {
"score": 7.57,
"sharpness": 3312.24,
"colorfulness": 42.2,
"contrast": 47.67,
"method": "heuristic-v1+nima-mobilenet-v1",
"nima_mean": 5.267,
"nima_std": 1.601
}| Field | Meaning |
|---|---|
score |
0–10 heuristic signal combining sharpness, colorfulness, and contrast. Cheap, and good at catching degenerate output (flat, blurred, washed out) |
sharpness, colorfulness, contrast |
The raw heuristic components, in their own units |
nima_mean |
NIMA aesthetic score on the 1–10 AVA scale, from a MobileNet-v1 model trained on human ratings |
nima_std |
Spread of NIMA's predicted rating distribution — a confidence signal, not a quality one |
method |
Which scorers ran: heuristic-v1, or heuristic-v1+nima-mobilenet-v1 when NIMA was included |
The two are deliberately not merged. score keeps the same meaning in every
record ever published, and NIMA is a learned judgement rather than a
measurement — treat nima_mean as arbitrary units useful for ranking
bauhaus's own outputs against each other, not as an absolute verdict. NIMA was
trained on photographs, so stylized artwork sits outside its training
distribution; it still reliably ranks blur, mud, and lost detail below a clean
result.
Scoring adds roughly 0.5 s to a run (weights load, then ~60 ms of inference)
and never fails it: if the model is missing or errors, the run logs a warning
and publishes with the heuristics alone. Pass --no-score to skip NIMA
entirely.
The weights are the MIT-licensed MobileNet-v1 NIMA model from
titu1994/neural-image-assessment
(0.0804 EMD on the AVA validation split), published in Keras format.
models/download_models.py fetches them under a pinned SHA-256 and converts
them once into models/weights/nima_mobilenet.npz, so generation needs only
torch and numpy. The PyTorch port matches the original Keras graph to within
float32 rounding (2.4e-07 max absolute difference on the output distribution).
10 curated CC0 paintings shipped in styles/, spanning Impressionism, Post-Impressionism, Japonisme, and Pointillism:
Monet, Hokusai, Cezanne, Turner, Hiroshige, Seurat, Degas, Klimt, Van Gogh, Gauguin
| Component | License |
|---|---|
| Code | MIT |
| Input art (Unsplash) | Unsplash License (allows derivatives and commercial use) |
| Input art (Met/AIC) | CC0 (public domain collections) |
| Style references | CC0 (same museum sources) |
| AdaIN model | MIT (naoto0804/pytorch-AdaIN) |
| NIMA scoring model | MIT (titu1994/neural-image-assessment) |
| VGG-19 encoder | BSD-like (torchvision) |
| Output images | CC0-1.0 for scheduled runs, which use the museum sources. Runs explicitly pointed at Unsplash carry the Unsplash License instead — each image's metadata.json records which. |