Pinterest Board to Image Collage Python Script
April 12, 2026
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
# ββ 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!")