← 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()