← back to Wallco Ai

docs/mural-pipeline.md

175 lines

# Room-size scenic-mural pipeline

Recreate Monterey (and other scenic) murals as ONE continuous **12 ft W × 11 ft H**
wall scene, sliced into customer-width vertical **panels** (24" / 36" / 54") for
print. Built 2026-06-03. Brief: *"the entire design is all the panels — create
room size to fit."*

This is the **non-repeating scenic** path. It is NOT for repeating patterns:
patterns tile infinitely so their native repeat IS the 150-DPI master (see
`build-tif.py` `compute_max_print` → 240"×132" for `seamless_tile`).

---

## Geometry (@ 150 DPI)

| Thing  | Inches      | Pixels          | Notes |
|--------|-------------|-----------------|-------|
| Master | 144 × 132   | 21,600 × 19,800 | the whole wall = "room size", 428 MP |
| Focal  | 144 × 120   | 21,600 × 18,000 | the protected scene |
| Bleed  | 6" top + 6" bottom | 900 px each | forgiving sky (top) / grass (bottom) — installer shift/trim zone |

**Panels** (customer picks width, master is sliced vertically, full height):

| Width | Panels for 12 ft | Per-panel px |
|-------|------------------|--------------|
| 24"   | 6 (even)         | 3,600 |
| 36"   | 4 (even)         | 5,400 |
| 54"   | 3 (2 full + 36" remainder) | 8,100 · 8,100 · 5,400 |

Because the master is 21,600 px across the full 144", **any** vertical slice is
automatically 150 DPI — no per-panel rescaling. Assemble all panels of one width
side-by-side → the complete scene.

---

## Why the old murals can't be physical (and the demotion)

Scenic murals were generated at **1024×1024**. 1024 px across 144" = **~7 DPI** —
unusable for print. So every mural that isn't a real 12 ft master must be
**removed from physical sale and kept as a digital download** (the digital path
serves an on-demand upscale, not the TIF).

`scripts/demote-lowres-murals.py` (REPORT-ONLY) scans `kind IN ('mural','mural_panel')`
for `tif_w_px < 21600` → **1,652 rows, 644 published**. It writes
`data/mural-demotion-plan.jsonl` and makes **zero DB changes**. Applying it
(unpublish-physical → digital-only) is **Steve-gated** — customer-facing on
hundreds of live rows, and there is **no `digital_only` schema column yet** (only
`digital_file_at`), so the surfacing mechanism must be decided before `--apply`.

---

## The build pipeline (`scripts/build-mural-master.py`)

Method chosen by DTD 2026-06-03 (Steve): **upscale existing art + generative
outpaint bleed**, with **tiled ESRGAN** for true detail.

Order is forced by model size limits (SDXL inpaint can't do 21k px), so:

```
source 1024²
  └─ cover_fit → low-res focal box 1080×900 (6:5, scale-to-cover + center-crop, NO stretch)
       └─ outpaint_bleed → adds 45px sky (top) + 45px grass (bottom) at low res
            via lucataco/sdxl-inpainting (Replicate, latest version, no pinned hash)
            → 1080×990 low-res master
                 └─ esrgan_tiled → 1080 → 21,600 px
                      nightmareai/real-esrgan x4, chained, but the model caps INPUT at
                      ~1448px (2.09M px GPU limit) — so each x4 level >1448px is TILED
                      (≤1200px tiles, 128px overlap, feather-blended) then restitched.
                      Loop runs a level only while max×2 < target (no huge overshoot),
                      LANCZOS finishes the <2× remainder.
                      → 21,600 × 19,800 master
                           └─ slice_panels → 24"/36"/54" vertical TIFs + manifest.json
```

Fallbacks: no Replicate token → pure-LANCZOS upscale + mirror+fade bleed (honest,
logged). Outpaint failure → mirror+fade bleed.

### Key finding — the 1448px ESRGAN cap
Replicate `real-esrgan` rejects inputs > ~1,448 px (`16.7M > GPU max 2.09M`). So
naive chained ESRGAN is effectively ONE pass (1024→4096); everything above is
LANCZOS. **Tiled ESRGAN** (split→upscale→restitch) is what gets true reconstructed
detail to 21,600 px. For wallco's flat screen-print silhouette art the difference
is nearly invisible (hard edges reconstruct cleanly even via LANCZOS), but tiled
is correct for detailed scenics.

### Key finding — bleed must be generated, not mirrored
The first proof used a deterministic mirror+fade bleed: where the tree canopy
touched the top edge it produced **upside-down branches in the "sky"**. So the top
bleed needs real generated sky (SDXL outpaint), which is why `--bleed=outpaint` is
the default. Mirror+fade remains as `--bleed=mirror` (fine where edges are already
clean sky/ground). Outpainted content is sky/grass only (negative-prompted against
birds/butterflies/people) — low settlement risk, but **run the settlement gate on
the final master before any publish** per the project hard rule.

### Replicate gotchas (hard-won 2026-06-03 — don't rediscover these)
The first real batch run hit a wall of Replicate API quirks. All fixed in
`build-mural-master.py`; documented so the next session doesn't burn an hour:

- **403 Forbidden on python urllib → set a browser User-Agent.** Replicate's
  Cloudflare edge 403s the default `Python-urllib/x` UA on the files-upload and
  model-GET endpoints (curl with its normal UA works fine). Fix: an
  `install_opener` with `User-Agent: Mozilla/5.0 …` process-wide (top of
  build-mural-master.py) — covers build-tif's esrgan calls too.
- **403 on large inline base64 → upload to the files API.** A ~1 MB image as an
  inline `data:image/png;base64,…` in the prediction body gets edge-403'd. Fix:
  `_replicate_upload()` POSTs the image to `/v1/files` (multipart) and passes the
  returned URL as the model input instead.
- **404 on the model-predictions endpoint → version-pin.** `POST /v1/models/
  {owner}/{name}/predictions` 404s for some models. Fix: GET the model, read
  `latest_version.id`, then `POST /v1/predictions` with `{version, input}` (the
  same path the working real-esrgan call uses).
- **One transient tile error crashed a whole mural → retry per tile.** A single
  HTTP 400 on one of a mural's ~17 tiled-ESRGAN calls aborted the build (21079
  failed this way). Fix: `_esrgan_x4_retry()` wraps each tile call in 4×
  backoff. Each mural is a fresh process, so adding retry mid-batch protected
  every subsequent mural immediately.
- **Inpaint model:** `lucataco/sdxl-inpainting` (latest version, resolved at
  call time — no pinned hash to rot).

### Reviewing built masters
Masters land on the Henry external volume (`/Volumes/Henry/wallco-ai-archive/
mural-masters/<id>/`), Mac2-local — NOT prod. Review them in the browser at
**`/admin/mural-masters`** (admin-gated local gallery): contact-sheet thumb +
12×11ft dims + per-width panel counts + size + created date+time chip, with
Sort + Density. Auto-refreshes every 30s as a batch lands more.

---

## Commands

```bash
# Geometry self-test — zero cost, no DB/net
python3 scripts/build-mural-master.py --self-test

# Cost estimate before spending — no calls made
python3 scripts/build-mural-master.py --estimate 12
#   → 12×11ft = 21,600×19,800; ~17 real-esrgan calls + 1 outpaint per mural
#   → ~$0.28/mural, ~$3.30 for the 12-mural batch

# Curate the first batch (read-only DB → manifest)
python3 scripts/curate-monterey-batch.py            # → data/mural-batch-monterey.json (12 hue-spread)

# Build ONE master (PAID — Replicate spend, multi-GB master on /Volumes/Henry)
python3 scripts/build-mural-master.py <id>                       # full 12×11ft, default outpaint+tiled
python3 scripts/build-mural-master.py <id> --print-ft 4 --height-ft 4   # bounded proof
python3 scripts/build-mural-master.py <id> --bleed mirror --upscale lanczos   # cheap/offline

# Build the curated batch (PAID) — the manifest carries the exact command
#   for id in $(...ids...); do python3 scripts/build-mural-master.py $id; done

# Visual QA of a built master (no spend)
python3 scripts/mural-contact-sheet.py <id>         # → <master_dir>/contact-sheet.png

# Demotion of low-res murals to digital-only (REPORT ONLY; apply is Steve-gated)
python3 scripts/demote-lowres-murals.py             # → data/mural-demotion-plan.jsonl
```

Masters land under `/Volumes/Henry/wallco-ai-archive/mural-masters/<id>/`
(`WALLCO_MURAL_DIR` override), each with `master_*.tif`, `panels_<w>in/`, and
`summary.json`.

Env knobs: `WALLCO_MURAL_PRINT_FT`, `WALLCO_ESRGAN_PASSES` (build-tif),
`WALLCO_MURAL_DIR`, `REPLICATE_API_TOKEN` (else read from
`~/Projects/animate-museum-posts/.env`).

---

## Parked for Steve (gated — never run autonomously)

1. **The paid 12×11ft batch** — built, proven, curated (12 ids in the manifest).
   Run `--estimate 12` to confirm ~$3.30, then the manifest's batch command.
2. **The low-res demotion `--apply`** — 644 live rows; customer-facing; needs a
   `digital_only` surfacing decision first. Report-only today.
3. **Publish** any new master only after the settlement gate returns OK.