← back to Wallco Ai
scripts/build-mural-master.py
526 lines
#!/usr/bin/env python3
"""
build-mural-master.py — room-size scenic-mural master + vertical-panel slicer.
Steve's brief (2026-06-03): recreate the Monterey scenic murals as ONE
continuous 12ft-wide x 11ft-tall scene ("the entire design is all the panels —
create room size to fit"). The wall is sliced into vertical PANELS the customer
picks the width of (24" / 36" / 54"). Assemble the panels side-by-side → the
complete scene.
Geometry @ 150 DPI:
master = 144" x 132" = 21,600 x 19,800 px (12ft W x 11ft H)
focal = 144" x 120" = 21,600 x 18,000 px (the protected scene)
bleed = top 6" + bottom 6" (900px each) of FORGIVING content (sky/clouds
up top, grass/ground at the bottom) so the installer can shift the
design up/down and trim WITHIN those bands without losing the scene.
Method (DTD verdict 2026-06-03, Steve's pick): UPSCALE existing curated art via
the Real-ESRGAN chain in build-tif.py, COVER-fit to the focal box (no stretch —
scale-to-cover + center-crop), then synthesize the 6" sky/grass bleed bands.
v1 bleed = deterministic edge-band mirror+fade (free, no model, never trips the
settlement foliage detectors). Generative outpaint (SDXL inpaint) is the
documented upgrade — drop it into make_bleed() behind --bleed=generative later.
Usage:
python3 scripts/build-mural-master.py <design_id> # full real build (ESRGAN, ~$0.12, multi-GB)
python3 scripts/build-mural-master.py <design_id> --panels 24,36 # only these panel widths
python3 scripts/build-mural-master.py --self-test # zero-cost geometry proof (no DB/net)
python3 scripts/build-mural-master.py <design_id> --print-ft 12 --height-ft 11 --bleed-in 6
Outputs (under WALLCO_MURAL_DIR or data/mural-masters/<id>/):
master_12x11ft_150dpi.tif
panels_24in/panel_01.tif ... panel_06.tif (+ manifest.json per width)
"""
import argparse
import importlib.util
import io
import json
import os
import sys
import time
from pathlib import Path
from PIL import Image
Image.MAX_IMAGE_PIXELS = None # a 21,600 x 19,800 master is ~428 MP — intentional.
ROOT = Path(__file__).resolve().parents[1]
# Reuse the ESRGAN upscaler + TIF writer from build-tif.py (hyphenated filename
# → load via importlib rather than import). Single source of truth for the
# Replicate mechanism, DPI, and the LZW-TIF writer.
_bt_spec = importlib.util.spec_from_file_location(
"buildtif", str(ROOT / "scripts" / "build-tif.py"))
_bt = importlib.util.module_from_spec(_bt_spec)
_bt_spec.loader.exec_module(_bt)
# Replicate's Cloudflare edge 403s the default `Python-urllib/x` User-Agent on
# some endpoints (files upload, model GET). Install a browser-ish UA process-
# wide so every urllib call (here AND in build-tif's _esrgan_x4) gets through.
import urllib.request as _ur
_op = _ur.build_opener()
_op.addheaders = [("User-Agent", "Mozilla/5.0 (Macintosh) wallco-mural/1.0")]
_ur.install_opener(_op)
DPI = _bt.DPI # 150
_HENRY = Path("/Volumes/Henry/wallco-ai-archive/mural-masters")
MURAL_DIR = Path(os.environ.get("WALLCO_MURAL_DIR")
or (_HENRY if _HENRY.parent.is_dir() else ROOT / "data" / "mural-masters"))
# ── geometry ────────────────────────────────────────────────────────────────
def cover_fit(img, box_w, box_h):
"""Scale-to-cover box_w x box_h then center-crop. No aspect distortion."""
img = img.convert("RGB")
w, h = img.size
scale = max(box_w / w, box_h / h)
nw, nh = max(box_w, round(w * scale)), max(box_h, round(h * scale))
img = img.resize((nw, nh), Image.LANCZOS)
left, top = (nw - box_w) // 2, (nh - box_h) // 2
return img.crop((left, top, left + box_w, top + box_h))
def make_bleed(focal, bleed_px, edge):
"""
Synthesize a forgiving bleed band of height bleed_px by extending the
focal's edge band. edge='top' → sky/clouds extended upward; edge='bottom'
→ grass/ground extended downward. Deterministic mirror+fade: sample a band
off the focal edge, mirror it, and fade toward the edge's mean tone so the
extension reads as "more sky"/"more ground", not a hard seam.
"""
w, _ = focal.size
sample_h = min(bleed_px, max(64, bleed_px // 2))
if edge == "top":
band = focal.crop((0, 0, w, sample_h))
else:
band = focal.crop((0, focal.size[1] - sample_h, w, focal.size[1]))
band = band.transpose(Image.FLIP_TOP_BOTTOM) # mirror so the seam matches
# Tile the mirrored band up to bleed_px tall.
out = Image.new("RGB", (w, bleed_px))
y = 0
while y < bleed_px:
out.paste(band, (0, y))
y += sample_h
out = out.crop((0, 0, w, bleed_px))
# Fade the far edge toward the band's mean tone for a soft, forgiving end.
from PIL import ImageStat
mean = tuple(int(c) for c in ImageStat.Stat(band).mean[:3])
solid = Image.new("RGB", (w, bleed_px), mean)
mask = Image.new("L", (w, bleed_px))
px = mask.load()
for yy in range(bleed_px):
# 0 at the focal-adjacent edge → 255 at the far (trim) edge.
t = yy / max(1, bleed_px - 1)
a = int(255 * (t ** 1.4))
row = a
for xx in range(0, w, 8): # coarse fill; mask is low-freq, 8px step is fine
for k in range(xx, min(xx + 8, w)):
px[k, yy] = row
if edge == "top":
mask = mask.transpose(Image.FLIP_TOP_BOTTOM) # far edge = the very top
return Image.composite(solid, out, mask)
def assemble_master(focal, bleed_px):
"""Stack top-bleed + focal + bottom-bleed into the full master."""
w, h = focal.size
top = make_bleed(focal, bleed_px, "top")
bot = make_bleed(focal, bleed_px, "bottom")
master = Image.new("RGB", (w, h + 2 * bleed_px))
master.paste(top, (0, 0))
master.paste(focal, (0, bleed_px))
master.paste(bot, (0, bleed_px + h))
return master
_ESRGAN_MAX_IN = 1448 # Replicate real-esrgan GPU cap (~2.09M px input)
def _esrgan_x4_retry(img, token, tries=4):
"""real-esrgan x4 with retries — a single transient Replicate error (400/
429/5xx/timeout) on any one of a mural's ~17 tile calls otherwise crashes
the whole build. Retry with backoff; re-raise only if all attempts fail."""
last = None
for i in range(tries):
try:
return _bt._esrgan_x4(img, token)
except Exception as e:
last = e
print(f" esrgan tile attempt {i+1}/{tries} failed ({str(e)[:70]}); retrying",
file=sys.stderr)
time.sleep(3 + i * 4)
raise last
def _replicate_run_latest(model, inp, token, poll_max=600):
"""Run a Replicate model's latest version via the version-pinned
/v1/predictions path (the model-predictions endpoint 404s for some models;
this resolves latest_version then POSTs like the working real-esrgan call)."""
import urllib.request, json as _json
greq = urllib.request.Request(
f"https://api.replicate.com/v1/models/{model}",
headers={"Authorization": "Bearer " + token})
ver = (_json.loads(urllib.request.urlopen(greq, timeout=30).read())
.get("latest_version") or {}).get("id")
if not ver:
raise RuntimeError(f"{model}: no latest_version")
req = urllib.request.Request(
"https://api.replicate.com/v1/predictions",
data=_json.dumps({"version": ver, "input": inp}).encode(),
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"})
pred = _json.loads(urllib.request.urlopen(req, timeout=90).read())
t0 = time.time()
while pred.get("status") not in ("succeeded", "failed", "canceled"):
if time.time() - t0 > poll_max:
raise RuntimeError("inpaint timeout")
time.sleep(3)
pred = _json.loads(urllib.request.urlopen(urllib.request.Request(
pred["urls"]["get"], headers={"Authorization": "Bearer " + token}), timeout=60).read())
if pred["status"] != "succeeded":
raise RuntimeError(f"{model} {pred['status']}: {str(pred.get('error'))[:160]}")
return pred["output"]
def _fetch_img(url):
import urllib.request
return Image.open(io.BytesIO(urllib.request.urlopen(url, timeout=180).read())).convert("RGB")
def _replicate_upload(im, token, name="img.png"):
"""Upload a PIL image to the Replicate files API → served URL. Large inline
base64 in the prediction body gets 403'd by Replicate's edge; uploaded URLs
don't. Returns the get-URL to pass as a model input."""
import urllib.request, json as _json, uuid
buf = io.BytesIO(); im.save(buf, format="PNG"); data = buf.getvalue()
boundary = "----wallco" + uuid.uuid4().hex
body = (f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="content"; filename="{name}"\r\n'
f"Content-Type: image/png\r\n\r\n").encode() + data + \
f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(
"https://api.replicate.com/v1/files", data=body,
headers={"Authorization": "Bearer " + token,
"Content-Type": f"multipart/form-data; boundary={boundary}"})
r = _json.loads(urllib.request.urlopen(req, timeout=120).read())
url = (r.get("urls") or {}).get("get")
if not url:
raise RuntimeError("file upload: no url in " + str(r)[:160])
return url
def esrgan_tiled(img, token, target_long):
"""
Upscale to target_long honouring the 1448px ESRGAN input cap: at each x4
level, tile the input into <=1448px tiles (with overlap), ESRGAN each, and
feather-blend the seams. Repeat levels until >= target, then exact LANCZOS.
Pure-LANCZOS fallback when no token.
"""
img = img.convert("RGB")
if not token:
s = target_long / max(img.size)
return img.resize((round(img.size[0]*s), round(img.size[1]*s)), Image.LANCZOS), "lanczos-only"
# Only run an x4 level while we're still under half target — a 4x jump then
# lands <=~2x target; LANCZOS the small remainder. Prevents huge overshoot
# (e.g. 1080->4320->17280, then LANCZOS->21600 — never 69120).
levels = 0
while max(img.size) * 2 < target_long and levels < 4:
if max(img.size) <= _ESRGAN_MAX_IN:
img = _esrgan_x4_retry(img, token); levels += 1; continue
img = _esrgan_x4_tiled(img, token); levels += 1
cw, ch = img.size
s = target_long / max(cw, ch)
if abs(s - 1) > 1e-3:
img = img.resize((round(cw*s), round(ch*s)), Image.LANCZOS)
return img, f"esrgan-tiled x{levels} + lanczos"
def _esrgan_x4_tiled(img, token, tile=1200, overlap=128):
"""One x4 level via overlapping tiles, feather-blended (no visible seams)."""
from PIL import ImageDraw
W, H = img.size
out = Image.new("RGB", (W*4, H*4))
acc = Image.new("L", (W*4, H*4), 0) # coverage for feather normalisation
step = tile - overlap
xs = list(range(0, max(1, W - overlap), step))
ys = list(range(0, max(1, H - overlap), step))
for ty in ys:
for tx in xs:
x0, y0 = tx, ty
x1, y1 = min(tx + tile, W), min(ty + tile, H)
piece = img.crop((x0, y0, x1, y1))
up = _esrgan_x4_retry(piece, token) # piece <= 1200 < 1448 cap
pw, ph = up.size
# feather mask: ramp in over the overlap on each interior edge
m = Image.new("L", (pw, ph), 255); d = ImageDraw.Draw(m)
ov = overlap * 4
for i in range(ov):
a = int(255 * (i / max(1, ov)))
if x0 > 0: d.line((i, 0, i, ph), fill=a)
if y0 > 0: d.line((0, i, pw, i), fill=a)
out.paste(up, (x0*4, y0*4), m)
return out
def outpaint_bleed(focal, bleed_px, top_prompt, bottom_prompt, token):
"""
Extend the focal by bleed_px top+bottom using SDXL inpainting (lucataco/
sdxl-inpainting, latest). Builds a canvas with empty bands + a white mask
over them and lets the model paint sky (top) / ground (bottom). Falls back
to make_bleed() mirror+fade if no token or the call fails. Runs at LOW res
(model size limit) — caller upscales the whole composition afterwards.
"""
w, h = focal.size
canvas = Image.new("RGB", (w, h + 2*bleed_px), (128, 128, 128))
canvas.paste(focal, (0, bleed_px))
# seed the bands with the focal edge colour so the model has context
from PIL import ImageStat
top_c = tuple(int(c) for c in ImageStat.Stat(focal.crop((0, 0, w, max(8, bleed_px//3)))).mean[:3])
bot_c = tuple(int(c) for c in ImageStat.Stat(focal.crop((0, h-max(8, bleed_px//3), w, h))).mean[:3])
canvas.paste(Image.new("RGB", (w, bleed_px), top_c), (0, 0))
canvas.paste(Image.new("RGB", (w, bleed_px), bot_c), (0, h + bleed_px))
mask = Image.new("L", (w, h + 2*bleed_px), 0)
mask.paste(Image.new("L", (w, bleed_px), 255), (0, 0))
mask.paste(Image.new("L", (w, bleed_px), 255), (0, h + bleed_px))
if not token:
return assemble_master(focal, bleed_px)
try:
img_url = _replicate_upload(canvas, token, "canvas.png")
mask_url = _replicate_upload(mask.convert("L"), token, "mask.png")
out = _replicate_run_latest("lucataco/sdxl-inpainting", {
"image": img_url, "mask": mask_url,
"prompt": f"{top_prompt} above, {bottom_prompt} below, soft natural "
"continuation of the scene, muted screen-print tones, no animals, no text",
"negative_prompt": "birds, butterflies, people, text, watermark, frame, border",
"num_inference_steps": 25, "guidance_scale": 7,
}, token)
url = out[0] if isinstance(out, list) else out
painted = _fetch_img(url).resize(canvas.size, Image.LANCZOS)
# keep the original focal crisp; only take the painted bands
painted.paste(focal, (0, bleed_px))
return painted
except Exception as e:
print(f" outpaint failed ({e}); mirror+fade fallback", file=sys.stderr)
return assemble_master(focal, bleed_px)
def slice_panels(master, panel_in, dpi=DPI):
"""
Slice the master into vertical panels panel_in inches wide (full height).
Returns a list of (index, x0, x1, width_px, width_in) — the last panel is
the remainder when panel_in doesn't divide the wall evenly.
"""
W, H = master.size
pw = int(round(panel_in * dpi))
panels, x, i = [], 0, 0
while x < W:
x1 = min(x + pw, W)
i += 1
panels.append((i, x, x1, x1 - x, round((x1 - x) / dpi, 2)))
x = x1
return panels
# ── full real build ─────────────────────────────────────────────────────────
def build(design_id, print_ft, height_ft, bleed_in, panel_widths,
bleed_method="outpaint", upscale_method="tiled", force=False):
master_w = int(round(print_ft * 12 * DPI)) # 21,600
master_h = int(round(height_ft * 12 * DPI)) # 19,800
bleed_px = int(round(bleed_in * DPI)) # 900
focal_w, focal_h = master_w, master_h - 2 * bleed_px # 21,600 x 18,000
conn = _bt.db()
d = _bt.fetch_design(conn, design_id)
conn.close()
if not d:
print(f"design {design_id} not found", file=sys.stderr); return 3
src = _bt.resolve_source(d)
if not src:
print(f"design {design_id}: source PNG not found", file=sys.stderr); return 4
token = _bt._replicate_token()
out_dir = MURAL_DIR / str(design_id)
out_dir.mkdir(parents=True, exist_ok=True)
master_path = out_dir / f"master_{print_ft:g}x{height_ft:g}ft_{DPI}dpi.tif"
if master_path.exists() and not force:
print(f"master exists {master_path} — use --force to rebuild");
else:
t0 = time.time()
img = Image.open(src)
print(f"design {design_id} ({d.get('kind')}) src {img.size[0]}x{img.size[1]} "
f"→ master {master_w}x{master_h}px ({print_ft:g}x{height_ft:g}ft @ {DPI}dpi)")
# Order is forced by model size limits: OUTPAINT must run at low res
# (SDXL inpaint can't do 21k px), so compose at low res then upscale the
# whole focal+bleed together for consistent detail.
LR_W = 1080
lr_fw, lr_fh = LR_W, round(LR_W * focal_h / focal_w) # 1080 x 900 (6:5)
lr_bleed = max(8, round(bleed_px * LR_W / master_w)) # 900*1080/21600 = 45
focal_lr = cover_fit(img, lr_fw, lr_fh)
if bleed_method == "outpaint":
master_lr = outpaint_bleed(focal_lr, lr_bleed,
"clear soft sky with gentle clouds",
"grassy ground meadow", token)
bmeth = "sdxl-outpaint" if token else "mirror(no-token)"
elif bleed_method == "mirror":
master_lr = assemble_master(focal_lr, lr_bleed); bmeth = "mirror+fade"
else:
master_lr = assemble_master(focal_lr, lr_bleed); bmeth = "mirror+fade"
print(f" low-res compose {master_lr.size[0]}x{master_lr.size[1]} "
f"(focal {lr_fw}x{lr_fh} + {bleed_in}\" bleed via {bmeth})")
if upscale_method == "tiled":
master, umeth = esrgan_tiled(master_lr, token, master_w)
else:
master, umeth = _bt.upscale_mural(master_lr, master_w)
if master.size != (master_w, master_h):
master = master.resize((master_w, master_h), Image.LANCZOS)
print(f" upscaled via {umeth} → master {master.size[0]}x{master.size[1]}")
_bt.write_tif(master, master_path)
print(f" ✓ master {master_path} ({master_path.stat().st_size/1e6:.0f} MB) "
f"in {time.time()-t0:.0f}s")
master = Image.open(master_path)
summary = {"design_id": design_id, "master": str(master_path),
"master_px": list(master.size), "dpi": DPI,
"print_ft": print_ft, "height_ft": height_ft, "bleed_in": bleed_in,
"panels": {}}
for pw_in in panel_widths:
pdir = out_dir / f"panels_{pw_in:g}in"
pdir.mkdir(exist_ok=True)
panels = slice_panels(master, pw_in)
man = []
for (i, x0, x1, wpx, win) in panels:
ppath = pdir / f"panel_{i:02d}.tif"
master.crop((x0, 0, x1, master.size[1])).save(
ppath, format="TIFF", compression="tiff_lzw", dpi=(DPI, DPI))
man.append({"index": i, "file": ppath.name, "x0": x0, "x1": x1,
"width_px": wpx, "width_in": win, "height_px": master.size[1]})
(pdir / "manifest.json").write_text(json.dumps(man, indent=2))
summary["panels"][f"{pw_in:g}in"] = {"count": len(man), "dir": str(pdir),
"panel_px": [m["width_px"] for m in man]}
print(f" ✓ {pw_in:g}\" → {len(man)} panels "
f"({', '.join(str(m['width_px']) for m in man)} px)")
(out_dir / "summary.json").write_text(json.dumps(summary, indent=2))
print(f"\nDONE → {out_dir}/summary.json")
return 0
# ── zero-spend cost estimator ────────────────────────────────────────────────
def _tiles_1d(n, tile=1200, overlap=128):
"""Tile count along one axis — mirrors _esrgan_x4_tiled's xs/ys."""
step = tile - overlap
return len(range(0, max(1, n - overlap), step))
def count_esrgan_calls(lr_long, target_long):
"""Mirror esrgan_tiled's level loop EXACTLY → number of real-esrgan calls."""
calls, cur, levels = 0, lr_long, 0
while cur * 2 < target_long and levels < 4:
calls += 1 if cur <= _ESRGAN_MAX_IN else _tiles_1d(cur) ** 2
cur *= 4
levels += 1
return calls
def estimate(print_ft, height_ft, bleed_in, panel_widths, n_murals,
esrgan_usd=0.015, inpaint_usd=0.02):
"""Print a no-spend cost + work estimate for a batch of n_murals."""
master_w = int(round(print_ft * 12 * DPI))
master_h = int(round(height_ft * 12 * DPI))
LR_W = 1080
eg = count_esrgan_calls(LR_W, master_w)
per_calls = eg + 1 # +1 SDXL outpaint
per_usd = eg * esrgan_usd + inpaint_usd
mp = master_w * master_h / 1e6
print(f"=== mural-master batch estimate (NO SPEND — arithmetic only) ===")
print(f"spec : {print_ft:g}ft x {height_ft:g}ft @ {DPI} DPI = "
f"{master_w:,} x {master_h:,} px ({mp:.0f} MP/master)")
print(f"bleed : {bleed_in:g}\" sky+grass top/bottom (SDXL outpaint, low-res)")
print(f"upscale path: 1080 -> {master_w:,} via tiled ESRGAN "
f"({eg} real-esrgan calls) + LANCZOS finish")
for pw in panel_widths:
n = len(slice_panels(Image.new('RGB', (master_w, 8), (0,0,0)), pw))
print(f" panels {pw:g}\" : {n} per mural")
print(f"\nper mural : ~{per_calls} Replicate calls ~${per_usd:.2f} "
f"(esrgan ${esrgan_usd}/call, inpaint ${inpaint_usd}/call)")
print(f"batch x{n_murals} : ~{per_calls*n_murals} calls ~${per_usd*n_murals:.2f}")
print(f"\n(estimates are upper-ish; real-esrgan often <$0.015/call. NO calls made.)")
return 0
# ── zero-cost geometry proof ─────────────────────────────────────────────────
def self_test():
"""Validate cover-fit + bleed + slicer with no DB, no network, no $ — at a
1/10-scale geometry, then assert the real 12x11ft panel math arithmetically."""
print("SELF-TEST — geometry only, no ESRGAN/DB/disk-master\n")
ok = True
# 1/10 scale: 2160 x 1980 master, 90px bleed, focal 2160 x 1800.
sc = 10
mw, mh, bp = 21600 // sc, 19800 // sc, 900 // sc
fw, fh = mw, mh - 2 * bp
src = Image.new("RGB", (102, 102))
for y in range(102): # vertical gradient so cover-fit/bleed are visible
for x in range(0, 102, 6):
for k in range(x, min(x + 6, 102)):
src.putpixel((k, y), (120, 150 + y // 3, 90))
focal = cover_fit(src, fw, fh)
assert focal.size == (fw, fh), focal.size
print(f" cover_fit square 102² → focal {focal.size} (expect {(fw, fh)}) ✓")
master = assemble_master(focal, bp)
assert master.size == (mw, mh), master.size
print(f" assemble focal+2*{bp}px bleed → master {master.size} (expect {(mw, mh)}) ✓")
# Real 12x11ft @150dpi panel math.
REAL_W = 12 * 12 * DPI # 21,600
cases = {24: 6, 36: 4, 54: 3}
for pw_in, expect_n in cases.items():
panels = slice_panels(Image.new("RGB", (REAL_W, 100)), pw_in)
widths = [p[3] for p in panels]
total = sum(widths)
good = len(panels) == expect_n and total == REAL_W
ok &= good
print(f" slice {pw_in}\" → {len(panels)} panels {widths}px "
f"(Σ={total}, expect {expect_n} panels / Σ{REAL_W}) {'✓' if good else '✗'}")
# Bleed band must be the right size and not throw.
tb = make_bleed(focal, bp, "top"); bb = make_bleed(focal, bp, "bottom")
assert tb.size == (fw, bp) and bb.size == (fw, bp)
print(f" bleed top/bottom bands {tb.size} ✓")
print("\n" + ("ALL GEOMETRY ASSERTIONS PASS ✓" if ok else "FAIL ✗"))
return 0 if ok else 1
def main():
p = argparse.ArgumentParser()
p.add_argument("design_id", type=int, nargs="?")
p.add_argument("--print-ft", type=float, default=12.0)
p.add_argument("--height-ft", type=float, default=11.0)
p.add_argument("--bleed-in", type=float, default=6.0)
p.add_argument("--panels", default="24,36,54",
help="comma-separated panel widths in inches (default 24,36,54)")
p.add_argument("--bleed", choices=("outpaint", "mirror", "none"), default="outpaint",
help="bleed-band fill: SDXL outpaint (default), mirror+fade, or none")
p.add_argument("--upscale", choices=("tiled", "lanczos"), default="tiled",
help="tiled ESRGAN (default, true detail) or single-pass+lanczos")
p.add_argument("--force", action="store_true")
p.add_argument("--self-test", action="store_true")
p.add_argument("--estimate", type=int, metavar="N",
help="print a no-spend cost estimate for an N-mural batch, then exit")
a = p.parse_args()
if a.self_test:
sys.exit(self_test())
if a.estimate is not None:
widths = [float(x) for x in a.panels.split(",") if x.strip()]
sys.exit(estimate(a.print_ft, a.height_ft, a.bleed_in, widths, a.estimate))
if a.design_id is None:
p.error("design_id is required (or pass --self-test)")
widths = [float(x) for x in a.panels.split(",") if x.strip()]
sys.exit(build(a.design_id, a.print_ft, a.height_ft, a.bleed_in, widths,
bleed_method=a.bleed, upscale_method=a.upscale, force=a.force))
if __name__ == "__main__":
main()