← back to Sister Parish Onboarding

scripts/tileability_check.py

98 lines

#!/usr/bin/env python3
"""
Quick tileability score for each Sister Parish primary_image.

Idea: a perfectly seamless tile has zero edge mismatch when wrapped left↔right
and top↔bottom. We measure the per-pixel difference between opposite edges
(20-pixel strips) and average it.

Scores:
  0 - 10  → effectively seamless (probably a true repeat)
  10 - 30 → near-seamless, would need slight matchup
  30 - 80 → photo with cropped pattern; not seamless
  80+     → clearly not a repeat (e.g. styled shot, room scene)

Writes to dw_unified.sisterparish_catalog.tileability JSONB.
"""
import json, os, subprocess
from pathlib import Path
from PIL import Image, ImageOps
import numpy as np

ROOT = Path.home() / 'Projects' / 'sister-parish-onboarding'

def psql_json(sql):
    out = subprocess.run(['psql','dw_unified','-At','-q','-c',sql],
                         capture_output=True, text=True, check=True).stdout.strip()
    return json.loads(out) if out else []

def edge_score(path, strip_px=20):
    img = Image.open(path).convert('RGB')
    img = ImageOps.exif_transpose(img)
    # Resize to a fixed working size so different image sizes are comparable
    img.thumbnail((800, 800))
    a = np.asarray(img, dtype=np.float32)
    h, w, _ = a.shape
    s = min(strip_px, w // 8, h // 8)
    # Horizontal seam: left strip vs right strip
    L = a[:, :s, :]
    R = a[:, -s:, :]
    horiz = float(np.mean(np.abs(L - R)))
    # Vertical seam: top vs bottom
    T = a[:s, :, :]
    B = a[-s:, :, :]
    vert = float(np.mean(np.abs(T - B)))
    return round((horiz + vert) / 2, 2), round(horiz, 2), round(vert, 2)

# Ensure column exists
subprocess.run(['psql','dw_unified','-q','-c',
    'ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS tileability JSONB;'], check=True)

# Pull each SP record's primary_image; we don't have local images for SP — only URLs.
# Use /tmp/sp_imgs/ which was populated by extract_dominant_hex.py
sp = psql_json("SELECT COALESCE(json_agg(t),'[]'::json) FROM (SELECT sp_product_id, title, primary_image FROM sisterparish_catalog WHERE primary_image IS NOT NULL) t;")

img_dir = Path('/tmp/sp_imgs')
scored = []
for s in sp:
    pid = s['sp_product_id']
    # Find image file (could be .jpg / .jpeg / .png)
    candidates = list(img_dir.glob(f'sp_{pid}.*'))
    if not candidates:
        continue
    img_path = candidates[0]
    try:
        score, h, v = edge_score(img_path)
        verdict = ('seamless' if score < 10
                   else 'near'    if score < 30
                   else 'photo'   if score < 80
                   else 'scene')
        scored.append((pid, score, h, v, verdict, s['title']))
    except Exception as e:
        print(f"  err {pid}: {e}")

# Write back
with open('/tmp/sp_tileability.sql','w') as f:
    f.write("BEGIN;\n")
    for pid, score, h, v, verdict, title in scored:
        obj = {"score": score, "edge_horiz": h, "edge_vert": v, "verdict": verdict}
        obj_str = json.dumps(obj).replace("'", "''")
        f.write(f"UPDATE sisterparish_catalog SET tileability='{obj_str}'::jsonb WHERE sp_product_id={pid};\n")
    f.write("COMMIT;\n")
subprocess.run(['psql','dw_unified','-q','-f','/tmp/sp_tileability.sql'], check=True)
os.unlink('/tmp/sp_tileability.sql')

print(f"\nScored {len(scored)} SP images")
# Verdict distribution
from collections import Counter
v_count = Counter(s[4] for s in scored)
print("Verdict distribution:", dict(v_count))

print("\nMost-tileable (top 8):")
for s in sorted(scored, key=lambda x: x[1])[:8]:
    print(f"  score={s[1]:>5.1f}  {s[4]:>9}  {s[5]}")

print("\nLeast-tileable (top 5):")
for s in sorted(scored, key=lambda x: x[1], reverse=True)[:5]:
    print(f"  score={s[1]:>5.1f}  {s[4]:>9}  {s[5]}")