Check an AI pattern tiles seamlessly with ImageChops.offset
Shift a Sume pattern image by half its size with ImageChops.offset and measure the seam stripe. A pass or fail score before you ship a repeating background.

Wrap the image by half its width and height with ImageChops.offset(img, w // 2, h // 2), which moves the old edges to the center cross, then compare pixels across that cross with their neighbors. A tile that repeats cleanly has no visible step there. A tile that does not has a hard line, and the score below turns that line into a number.
Why prompts do not guarantee a tile
Asking for a seamless pattern makes an image model draw a pattern, but not one whose left edge matches its right edge. Some models come close, many do not. Sume's image models do not have a tile parameter in the catalog, so you check the result yourself.
The offset trick is the standard check in texture work: put the seam where you can see it. If you can see it, a visitor will, once the tile repeats across a page.
Score the seam
After the offset, the old edges sit on the middle row and middle column. The code takes a thin strip across each and compares it with a strip just beside it using ImageChops.difference, then reads the mean with ImageStat. The comparison strip is 4 pixels from the seam.
import os, io, requests
from PIL import Image
H = {"Authorization": "Bearer " + os.environ["SUME_API_KEY"]}
def gen(**body):
r = requests.post("https://api.sume.com/v1/images", json=body, headers=H, timeout=60)
r.raise_for_status()
if r.status_code == 202:
raise SystemExit("queued, read /v1/jobs/{id}/result: " + r.text)
return r.json()
def fetch(u):
return Image.open(io.BytesIO(requests.get(u, timeout=60).content))
from PIL import ImageChops, ImageStat
out = gen(model="black-forest-labs/flux.2-pro", aspect_ratio="1:1",
prompt="Seamless repeating pattern of small blue paper boats on cream, flat design")
img = fetch(out["data"][0]["url"]).convert("L")
w, h = img.size
sh = ImageChops.offset(img, w // 2, h // 2)
def strip_diff(box_a, box_b):
d = ImageChops.difference(sh.crop(box_a), sh.crop(box_b))
return ImageStat.Stat(d).mean[0]
vert = strip_diff((w // 2 - 1, 0, w // 2 + 1, h), (w // 2 + 3, 0, w // 2 + 5, h))
horz = strip_diff((0, h // 2 - 1, w, h // 2 + 1), (0, h // 2 + 3, w, h // 2 + 5))
base = ImageStat.Stat(ImageChops.difference(sh.crop((0, 0, w - 4, h)), sh.crop((4, 0, w, h)))).mean[0]
print("seam v", round(vert, 1), "h", round(horz, 1), "baseline", round(base, 1), "cost", out["usage"]["cost"])
print("PASS" if max(vert, horz) < base * 1.5 + 2 else "SEAM VISIBLE")
sh.save("offset_view.png")Reading the numbers
Compare the seam score with the baseline, which is how much neighboring columns differ elsewhere in the image. Busy patterns have a high baseline, so a fixed threshold would fail them wrongly.
| Seam vs baseline | Meaning | Action |
|---|---|---|
| Under 1.5x | No visible step | Ship the tile |
| 1.5x to 3x | Faint line | Blend the seam or regenerate |
| Over 3x | Hard cut | Regenerate with a simpler pattern |
If the tile fails
- Ask for a simpler, evenly spaced motif. Large single objects and gradients cross edges badly.
- Open
offset_view.pngand look. The score finds the seam, but your eyes decide if it matters. - Fix small seams by cloning: paste a blurred strip over the cross. For big seams, regenerate.
- Try a different model before a different prompt, since the same text gives a different edge behavior on each.
Next steps
Add the check to your prompt regression test so a model swap flags new seams. Once a tile passes, export the widths you need with the srcset recipe, and save only lossless files during repair, per PNG between passes.
Sources
Related posts
More in Media tools
- Check on-screen text in a Wan 3.0 clip: video frames at exact seconds
Wan 3.0 claims text rendering in 12 languages. Before you ship, pull PNG stills at the seconds the text appears with Sume video frames and read them yourself.
- Coffee roaster brand video music: a 40-second lofi bed
Brief a warm 40-second lofi bed with vinyl texture for a coffee roaster brand video: one generation and a one-minute render, $0.225 on Sume.
- Coffee roaster holiday video: caption in your brand colour with design
Caption a roaster's holiday video in a brand colour with `design.colors.active`, then restyle twice without a second transcription: three looks for $0.60.
- Color-grade AI video on Sume: lut3d is off, tone filters stay
Sume video filter has a dim op, a crop op and an allowlisted ffmpeg filtergraph. lut3d is not on it. What a grade can and cannot do, per the docs.
Written by Sume