Dead Robot Logo Right Logo

Blog πŸ€–


↩ Back to Blog ← Previous Post Next Post β†’
Pinterest Collage Script

Pinterest Board to Image Collage Python Script

i vibe-coded a handy python script for google colab for taking a pinterest board url, & compiling the images into a collage, and also individually downloading them in a zip file for you

i made this so when building mood boards using Obsidian it's easier to consolidate and source multiple references all at once

if anyone is curious to test it out themselves, here r the steps

1. make a new Google Colab notebook

2. paste the full code into a cell

3. update the "BOARD_URL" to match the pinterest board you're looking to download/make a collage of

4. you can also adjust the collage canvas size and other settings like borders to taste in that same section

5. make sure you're connected to a runtime and run the cell, then download the files (the collage jpg and the .zip file)

6. change the "SEARCH_ATTEMPTS" variable to a lower number for a messier but faster result

python
# ── CONFIG (only change this) ────────────────────────────────────────────────
BOARD_URL = "https: //www.pinterest.com/username/boardname/"
CANVAS_W, CANVAS_H = 2160, 3840
COLLAGE_QUALITY = 92
BG_COLOR = (0, 0, 0)

NUM_COLS         = 0
MIN_SCALE        = 0.8
MAX_SCALE        = 1.2
SCALE_RANDOMNESS = 0.3
SEARCH_ATTEMPTS  = 80000
CANVAS_WIGGLE    = 0.10
RANDOM_SEED      = None

BORDER           = 6
OUTER_BORDER     = 20
# ────────────────────────────────────────────────────────────────────────────

import os, math, shutil, subprocess, sys, random, re
subprocess.check_call([sys.executable, "-m", "pip", "install", "gallery-dl", "Pillow", "-q"])

from PIL import Image
from google.colab import files

match = re.match(r'https?://www\.pinterest\.com/([^/]+)/([^/]+)/?', BOARD_URL)
if not match:
    raise ValueError("Couldn't parse Pinterest URL")
USERNAME   = match.group(1)
BOARD_SLUG = match.group(2)
BOARD_NAME = BOARD_SLUG.replace('-',' ').title().replace(' ','')
FILE_STEM  = f"{BOARD_NAME}-{USERNAME}"
print(f"🎯 Board: {BOARD_NAME} by {USERNAME}")

OUTPUT_DIR   = f"/content/{FILE_STEM}"
COLLAGE_PATH = f"/content/{FILE_STEM}.jpg"
ZIP_PATH     = f"/content/{FILE_STEM}.zip"
for path in [OUTPUT_DIR, COLLAGE_PATH, ZIP_PATH]:
    if os.path.isdir(path):    shutil.rmtree(path)
    elif os.path.isfile(path): os.remove(path)
os.makedirs(OUTPUT_DIR, exist_ok=True)

print("\nπŸ“Œ Downloading Pinterest board...")
result = subprocess.run(["gallery-dl", "-d", OUTPUT_DIR, BOARD_URL])
if result.returncode != 0:
    print("⚠️  gallery-dl had issues. Continuing with any images downloaded...")

all_files = sorted([
    os.path.join(root, f)
    for root, _, fnames in os.walk(OUTPUT_DIR)
    for f in fnames
    if f.lower().endswith(('.jpg','.jpeg','.png','.webp'))
])
print(f"βœ… Found {len(all_files)} images")
if not all_files:
    print("❌ No images found.")
    raise SystemExit

print("πŸ“ Loading metadata...")
images = []
for fp in all_files:
    try:
        with Image.open(fp) as img:
            w, h = img.size
            images.append({'path': fp, 'w': w, 'h': h, 'ratio': w / h})
    except Exception as e:
        print(f"  skipping {os.path.basename(fp)}: {e}")
n = len(images)
print(f"  {n} images loaded")

# ── Canvas candidates ─────────────────────────────────────────────────────────
def make_canvas_candidates(base_w, base_h, wiggle, n_steps=7):
    w_lo, w_hi = round(base_w*(1-wiggle)), round(base_w*(1+wiggle))
    h_lo, h_hi = round(base_h*(1-wiggle)), round(base_h*(1+wiggle))
    ws = [round(w_lo + (w_hi-w_lo)*i/(n_steps-1)) for i in range(n_steps)]
    hs = [round(h_lo + (h_hi-h_lo)*i/(n_steps-1)) for i in range(n_steps)]
    return [(w, h) for w in ws for h in hs]

canvas_candidates = make_canvas_candidates(CANVAS_W, CANVAS_H, CANVAS_WIGGLE)
print(f"  {len(canvas_candidates)} canvas candidates")

# ── Layout engine ─────────────────────────────────────────────────────────────
def column_layout(imgs, n_cols, rng, randomness, cw, ch):
    inner_w = cw - 2 * OUTER_BORDER
    inner_h = ch - 2 * OUTER_BORDER
    if inner_w <= 0 or inner_h <= 0:
        return [], [], []

    weights = []
    for img in imgs:
        w = 1.0
        if randomness > 0:
            lo = 1.0 - randomness*(1.0-MIN_SCALE)
            hi = 1.0 + randomness*(MAX_SCALE-1.0)
            w = rng.uniform(lo, hi)
        weights.append(w)

    cols = [[] for _ in range(n_cols)]
    for i, (img, wt) in enumerate(zip(imgs, weights)):
        cols[i % n_cols].append({'img': img, 'weight': wt})

    h_gaps   = BORDER * (n_cols - 1)
    usable_w = inner_w - h_gaps

    col_avg_ratios = []
    for col in cols:
        if not col:
            col_avg_ratios.append(1.0)
            continue
        total_wt = sum(c['weight'] for c in col)
        avg_r = sum(c['img']['ratio']*c['weight'] for c in col) / total_wt
        col_avg_ratios.append(avg_r)

    total_r    = sum(col_avg_ratios)
    col_widths = [max(1, round(r/total_r*usable_w)) for r in col_avg_ratios]
    diff = usable_w - sum(col_widths)
    col_widths[col_widths.index(max(col_widths))] += diff

    placements, bottom_ys = [], []
    x = OUTER_BORDER

    for ci, (col, col_w) in enumerate(zip(cols, col_widths)):
        if not col:
            x += col_w + BORDER
            bottom_ys.append(OUTER_BORDER)
            continue

        v_gaps  = BORDER * (len(col) - 1)
        raw_dhs = [max(1, round((col_w / c['img']['ratio']) * c['weight']))
                   for c in col]

        y, col_bottom = OUTER_BORDER, OUTER_BORDER
        for c, dh in zip(col, raw_dhs):
            dw    = col_w
            max_dh = (ch - OUTER_BORDER) - y - v_gaps
            dh    = max(1, min(dh, max_dh))
            placements.append({**c['img'],
                'x': x, 'y': y, 'dw': dw, 'dh': dh, 'col': ci})
            col_bottom = y + dh
            y += dh + BORDER

        bottom_ys.append(col_bottom)
        x += col_w + BORDER

    return placements, col_widths, bottom_ys

def ratio_error(pl):
    if not pl: return 999.0
    errs = [abs((p['dw']/p['dh'])-p['ratio'])/p['ratio']*100
            for p in pl if p['dh'] > 0]
    return sum(errs)/len(errs) if errs else 0.0

def bottom_remainder(bottom_ys, ch):
    if not bottom_ys: return float('inf')
    target = ch - OUTER_BORDER
    return sum(abs(target - b) for b in bottom_ys) / len(bottom_ys)

def bottom_variance(bottom_ys):
    if not bottom_ys: return float('inf')
    mean = sum(bottom_ys)/len(bottom_ys)
    return sum((b-mean)**2 for b in bottom_ys)/len(bottom_ys)

def canvas_deviation(uw, uh):
    return (abs(uw-CANVAS_W)/CANVAS_W + abs(uh-CANVAS_H)/CANVAS_H)/2*100

def score(pl, by, cw, ch, n_total):
    if not pl: return float('inf')
    missing  = (n_total - len(pl)) * 10000
    r_err    = ratio_error(pl)
    bot_rem  = bottom_remainder(by, ch) / ch * 100
    bot_var  = math.sqrt(bottom_variance(by)) / ch * 100
    c_dev    = canvas_deviation(cw, ch) * 0.5
    return missing + r_err + bot_rem * 3 + bot_var * 2 + c_dev

# ── Search ────────────────────────────────────────────────────────────────────
print(f"πŸ”¬ Running {SEARCH_ATTEMPTS} iterations...")
rng = random.Random(RANDOM_SEED)

if NUM_COLS > 0:
    col_counts = [NUM_COLS]
else:
    natural = max(2, round(math.sqrt(n*CANVAS_W/CANVAS_H)))
    col_counts = sorted(set(range(max(2, natural-2), natural+4)))
print(f"  Column counts to try: {col_counts}")

best_placements, best_bottom_ys, best_score_val, best_canvas = None, None, float('inf'), (CANVAS_W, CANVAS_H)

orderings_base = {
    'ratio↑': sorted(images, key=lambda x: x['ratio']),
    'ratio↓': sorted(images, key=lambda x: -x['ratio']),
    'natural': images[:],
    'area↓':  sorted(images, key=lambda x: -(x['w']*x['h'])),
}

attempt = 0
for n_cols in col_counts:
    for label, order in orderings_base.items():
        for (cw, ch) in canvas_candidates:
            pl, _, by = column_layout(order, n_cols, rng, SCALE_RANDOMNESS, cw, ch)
            s = score(pl, by, cw, ch, n)
            if s < best_score_val:
                best_score_val, best_placements, best_bottom_ys, best_canvas = s, pl, by, (cw,ch)
                rem = bottom_remainder(by, ch)
                print(f"  ✨ [cols={n_cols} {label} {cw}Γ—{ch}]  placed={len(pl)}/{n}  "
                      f"rerr={ratio_error(pl):.2f}%  bot_rem={rem:.0f}px  "
                      f"bot_std={math.sqrt(bottom_variance(by)):.0f}px")
            attempt += 1

while attempt < SEARCH_ATTEMPTS:
    n_cols   = rng.choice(col_counts)
    cw, ch   = rng.choice(canvas_candidates)
    shuffled = images[:]
    rng.shuffle(shuffled)
    pl, _, by = column_layout(shuffled, n_cols, rng, SCALE_RANDOMNESS, cw, ch)
    s = score(pl, by, cw, ch, n)
    if s < best_score_val:
        best_score_val, best_placements, best_bottom_ys, best_canvas = s, pl, by, (cw,ch)
        rem = bottom_remainder(by, ch)
        print(f"  ✨ {attempt:05d} [cols={n_cols} {cw}Γ—{ch}]  placed={len(pl)}/{n}  "
              f"rerr={ratio_error(pl):.2f}%  bot_rem={rem:.0f}px  "
              f"bot_std={math.sqrt(bottom_variance(by)):.0f}px")
    if attempt % 5000 == 0:
        rem = bottom_remainder(best_bottom_ys, best_canvas[1])
        print(f"  ... {attempt}/{SEARCH_ATTEMPTS}  canvas={best_canvas}  "
              f"rerr={ratio_error(best_placements):.2f}%  bot_rem={rem:.0f}px")
    attempt += 1

final_w, final_h = best_canvas
rem_final = bottom_remainder(best_bottom_ys, final_h)
print(f"\n✨ Done: {len(best_placements)}/{n} placed  canvas={final_w}Γ—{final_h}")
print(f"  rerr={ratio_error(best_placements):.2f}%  bot_rem={rem_final:.0f}px  "
      f"bot_std={math.sqrt(bottom_variance(best_bottom_ys)):.0f}px")

# ── Vertical centering ────────────────────────────────────────────────────────
# Top margin is always OUTER_BORDER (by construction).
# Bottom margin = final_h - max(col_bottom across all columns).
# If bottom margin > top margin, shift everything down by half the difference.
# If top margin > bottom margin, shift everything up by half the difference.
# Result: top margin == bottom margin == (top_margin + bottom_margin) / 2.

top_margin    = OUTER_BORDER
bottom_edge   = max(best_bottom_ys) if best_bottom_ys else final_h - OUTER_BORDER
bottom_margin = final_h - bottom_edge
total_slack   = top_margin + bottom_margin
even_margin   = total_slack / 2
y_shift       = round(even_margin - top_margin)   # positive = shift down, negative = shift up

print(f"\nπŸ“ Vertical centering:")
print(f"  Top margin:    {top_margin}px")
print(f"  Bottom margin: {bottom_margin}px")
print(f"  Shifting all images by {y_shift:+d}px β†’ even margin: {round(even_margin)}px each side")

if y_shift != 0:
    for p in best_placements:
        p['y'] += y_shift

# ── Render ────────────────────────────────────────────────────────────────────
print("πŸ–ΌοΈ  Rendering...")
canvas = Image.new('RGB', (final_w, final_h), BG_COLOR)

for i, p in enumerate(best_placements):
    try:
        img = Image.open(p['path']).convert('RGB')
        img = img.resize((max(1, p['dw']), max(1, p['dh'])), Image.LANCZOS)
        canvas.paste(img, (p['x'], p['y']))
    except Exception as e:
        print(f"  skipping: {e}")
    if i % 20 == 0:
        print(f"  pasting {i+1}/{len(best_placements)}...")

canvas.save(COLLAGE_PATH, 'JPEG', quality=COLLAGE_QUALITY)
print(f"βœ… Saved: {final_w}Γ—{final_h}px β†’ {FILE_STEM}.jpg")

print("\nπŸ“¦ Zipping...")
shutil.make_archive(f"/content/{FILE_STEM}", 'zip', OUTPUT_DIR)
print(f"⬇️  Downloading {FILE_STEM}.jpg ...")
files.download(COLLAGE_PATH)
print(f"⬇️  Downloading {FILE_STEM}.zip ...")
files.download(ZIP_PATH)
print("\nπŸŽ‰ Done!")