Proof watermark for client drafts of AI images, in Pillow

Stamp a tiled diagonal PROOF text over an image from Sume before sending drafts. Layer, rotate, alpha-composite, and why it is a courtesy and not security.

5 min readSume
All posts

Draw the text on a transparent layer, rotate it with Image.rotate(30, expand=True), crop it to the image size, and combine with Image.alpha_composite. Tile the word across the whole picture so a crop cannot remove it. This marks a draft as a draft. It is a visible overlay you add yourself, not a security feature, and anyone with the original file has the clean image.

What this is and is not

Client approval rounds often need proofs that cannot be mistaken for finals. A bold tiled word does that job. It also stops a draft from being used by accident on a live page.

It does not protect the file. If you need to withhold the clean image, serve only the proof and keep the original on your server. This overlay is your own mark; it does nothing to any provenance data inside the file, and it should not be used to hide such data.

The stamp

The layer is drawn at 1.5 times the diagonal so rotating leaves no empty corners. Alpha around 70 out of 255 stays readable without covering the product. Pillow's built-in font is small, so scale by drawing at a larger size when you have a TrueType file, using ImageFont.truetype(path, size).

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 ImageDraw
out = gen(model="bytedance-seed/seedream-4.5", aspect_ratio="4:3",
          prompt="Modern living room with a gray sofa and a green plant, daylight")
img = fetch(out["data"][0]["url"]).convert("RGBA")
w, h = img.size
side = int((w * w + h * h) ** 0.5)
layer = Image.new("RGBA", (side, side), (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
for y in range(0, side, 160):
    for x in range(0, side, 320):
        d.text((x, y), "PROOF", fill=(255, 255, 255, 70))
layer = layer.rotate(30, expand=True)
left, top = (layer.width - w) // 2, (layer.height - h) // 2
layer = layer.crop((left, top, left + w, top + h))
Image.alpha_composite(img, layer).convert("RGB").save("proof.jpg", quality=85)
print("saved", w, h, "cost", out["usage"]["cost"])

Settings by purpose

Pick strength by how much damage a leaked draft would do. The proof file is also a good place to drop the quality and size.

Proof settings, values read 2026-10-05
PurposeAlpha (0-255)Tile spacingExport
Internal review40480 x 240JPEG quality 85
Client draft70320 x 160JPEG quality 80, 1280 px wide
Public preview110240 x 120JPEG quality 70, 800 px wide

Practical tips

  • Draw white text on dark images and dark text on light ones, or both with an offset, so it shows everywhere.
  • Rotate and tile. A single corner mark is cropped out in one click.
  • Keep file names distinct: -proof versus -final, so a draft is never uploaded as a final.
  • Export proofs at lower size. It cuts transfer time and gives away less.

Fit it in your flow

Generate drafts, stamp proofs, send, and run the approved prompt again for the final if the model is deterministic enough for your needs. Compare candidates with the duplicate finder to avoid sending two near-identical proofs, track prompts with the regression test, and derive web sizes with the srcset recipe.

Sources

Related posts

More in Use cases

All Use cases posts

Written by Sume