← back to Trending Dw

scripts/make_tileable.py

115 lines

#!/usr/bin/env python3
"""make_tileable.py — turn a non-tiling SDXL wallpaper image into a TRUE
seamless tile, with NO center-seam smear.

Two modes (auto-selected by motif coverage):

  motif-wrap  (discrete motifs on a dominant flat/near-uniform ground):
     1. estimate ground color (dominant coarse-quantized bucket)
     2. build a seamless GROUND by offset+feather of the ground-filled image
        (uniform → feather is invisible)
     3. keep only motif blobs fully INTERIOR (drop any connected component that
        touches an edge margin — those are clipped and cannot wrap)
     4. wrap-composite the interior motifs at the 9 neighbor offsets so motifs
        near a border also appear on the opposite border → fills margins AND
        guarantees the tile wraps. Motifs stay crisp (no smear).

  offset-feather (allover texture / tonal / blur — no discrete motifs):
     offset by (W/2,H/2) then a WIDE cosine cross-fade over the center cross.
     On a uniform-ish field this is invisible and drives all six seam lenses low.

Usage: make_tileable.py <in.png> <out.png> [--mode auto|motif|texture]
                        [--margin 44] [--mask-th 34] [--band 130]
"""
import argparse, numpy as np
from PIL import Image
from scipy import ndimage


def ground_color(im):
    q = (im.astype(np.int32) // 12).reshape(-1, 3)
    vals, counts = np.unique(q, axis=0, return_counts=True)
    return (vals[counts.argmax()] * 12 + 6).astype(np.float64)


def offset_feather(im, band):
    H, W, _ = im.shape
    r = np.roll(np.roll(im, H // 2, 0), W // 2, 1)
    out = r.copy()
    B = min(band, H // 2 - 1, W // 2 - 1)
    # vertical seam at x=W//2: linear cross-fade the band [c-B, c+B) between the
    # two anchor columns just outside the band -> C0 continuous, no seam.
    c = W // 2
    A = r[:, c - B - 1:c - B, :]          # (H,1,3)
    Z = r[:, c + B:c + B + 1, :]          # (H,1,3)
    t = (np.arange(2 * B) + 1.0) / (2 * B + 1.0)
    ramp = t[None, :, None]
    out[:, c - B:c + B, :] = A * (1 - ramp) + Z * ramp
    # horizontal seam at y=H//2 on the vertically-healed result
    m = H // 2
    A2 = out[m - B - 1:m - B, :, :]
    Z2 = out[m + B:m + B + 1, :, :]
    ramp2 = t[:, None, None]
    out[m - B:m + B, :, :] = A2 * (1 - ramp2) + Z2 * ramp2
    return out


def motif_wrap(im, margin, mask_th, band):
    H, W, _ = im.shape
    g = ground_color(im)
    dist = np.sqrt(((im - g[None, None, :]) ** 2).sum(2))
    mask = dist > mask_th
    # seamless textured ground from the ground-filled (uniform) image
    gl = im.copy()
    gl[mask] = g
    ground_seam = offset_feather(gl, band)
    # connected components; drop any that touch the edge margin
    lbl, n = ndimage.label(mask)
    keep = np.zeros_like(mask)
    edge_labels = set()
    edge_labels |= set(np.unique(lbl[:margin, :]))
    edge_labels |= set(np.unique(lbl[-margin:, :]))
    edge_labels |= set(np.unique(lbl[:, :margin]))
    edge_labels |= set(np.unique(lbl[:, -margin:]))
    edge_labels.discard(0)
    for lab in range(1, n + 1):
        if lab not in edge_labels:
            keep |= (lbl == lab)
    # wrap-composite interior motifs at 9 neighbor offsets
    canvas = ground_seam.copy()
    ys, xs = np.where(keep)
    for dy in (-H, 0, H):
        for dx in (-W, 0, W):
            ny, nx = ys + dy, xs + dx
            ok = (ny >= 0) & (ny < H) & (nx >= 0) & (nx < W)
            canvas[ny[ok], nx[ok]] = im[ys[ok], xs[ok]]
    cov = mask.mean()
    return canvas, cov, n


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('inp'); ap.add_argument('outp')
    ap.add_argument('--mode', default='auto')
    ap.add_argument('--margin', type=int, default=44)
    ap.add_argument('--mask-th', type=int, default=34)
    ap.add_argument('--band', type=int, default=130)
    a = ap.parse_args()
    im = np.asarray(Image.open(a.inp).convert('RGB')).astype(np.float64)
    mode = a.mode
    if mode == 'auto':
        g = ground_color(im)
        cov = (np.sqrt(((im - g[None, None, :]) ** 2).sum(2)) > a.mask_th).mean()
        mode = 'motif' if cov < 0.48 else 'texture'
    if mode == 'motif':
        out, cov, n = motif_wrap(im, a.margin, a.mask_th, a.band)
        print(f'mode=motif coverage={cov:.3f} components={n}')
    else:
        out = offset_feather(im, a.band)
        print('mode=texture')
    Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)).save(a.outp)


if __name__ == '__main__':
    main()