This documentation page is also available as an interactive notebook. You can launch the notebook in
Kaggle or Colab, or download it for use with an IDE or local Jupyter installation, by clicking one of the
above links.
Problem
You need to add watermarks to hundreds of different images to protect
copyright, add branding, or mark drafts.
Solution
What’s in this recipe:
- Create simple text watermarks
- Test transformations before applying
- Apply to multiple images automatically
You add watermarks to images using a custom UDF that wraps Pillow’s
ImageDraw (relies on PIL/Pillow). This gives you full control over
watermark placement, font, transparency, and color.
You can iterate on transformations before adding them to your table. Use
.select() with .collect() to preview results on sample
images—nothing is stored in your table. If you want to collect only the
first few rows, use .head(n) instead of .collect(). Once you’re
satisfied, use .add_computed_column() to apply watermarks to all
images in your table.
For more on this workflow, see Get fast feedback on
transformations.
Setup
%pip install -qU pixeltable
import pixeltable as pxt
from PIL import Image, ImageDraw, ImageFont
Load images
# Create a fresh directory (drop existing if present)
pxt.drop_dir('image_demo', force=True)
pxt.create_dir('image_demo')
Connected to Pixeltable database at: postgresql+psycopg://postgres:@/pixeltable?host=/Users/alison-pxt/.pixeltable/pgdata
Created directory ‘image_demo’.
<pixeltable.catalog.dir.Dir at 0x135490e90>
t = pxt.create_table('image_demo.watermarks', {'image': pxt.Image})
Created table ‘watermarks’.
t.insert([
{'image': 'https://raw.githubusercontent.com/pixeltable/pixeltable/main/docs/resources/images/000000000049.jpg'},
{'image': 'https://raw.githubusercontent.com/pixeltable/pixeltable/main/docs/resources/images/000000000036.jpg'},
{'image': 'https://raw.githubusercontent.com/pixeltable/pixeltable/main/docs/resources/images/000000000090.jpg'},
])
Inserting rows into `watermarks`: 3 rows [00:00, 891.46 rows/s]
Inserted 3 rows with 0 errors.
3 rows inserted, 6 values computed.
Iterate: Add watermarks to a few images first
@pxt.udf
def add_watermark(img: Image.Image, text: str) -> Image.Image:
"""Add a watermark to bottom-right corner."""
img = img.copy().convert('RGBA')
overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Draw white text in bottom-right corner
font = ImageFont.load_default(size=40)
position = (img.width - 150, img.height - 60)
draw.text(position, text, font=font, fill=(255, 255, 255, 200))
result = Image.alpha_composite(img, overlay)
return result.convert('RGB')
# Test on first image
t.select(t.image, add_watermark(t.image, '© 2024')).head(1)
Add: Add watermarks to all images in your table
# Add watermark to all images
t.add_computed_column(watermarked=add_watermark(t.image, '© 2024'))
Added 3 column values with 0 errors.
3 rows updated, 3 values computed.
# View all results
t.collect()
Explanation
How the watermark technique works:
The UDF creates a transparent overlay on top of each image. The overlay
is created with the same dimensions as the image
(Image.new('RGBA', img.size, ...)), so watermarks adapt automatically
whether you’re processing small thumbnails or large photos. The function
draws white text with semi-transparent fill (alpha=200, where 255 is
fully opaque), composites the overlay onto the original image using
Image.alpha_composite(), and converts back to RGB since most image
formats don’t support transparency.
To customize the UDF: - Position: Change the (x, y) coordinates in
the position variable - Color: Modify the (R, G, B, Alpha) fill
value (0-255 for each) - Size: Adjust the font size parameter in
ImageFont.load_default(size=40) - Font: Use
ImageFont.truetype('path/to/font.ttf', size) for custom fonts
The Pixeltable workflow:
In traditional databases, .select() just picks which columns to view.
In Pixeltable, .select() also lets you compute new transformations on
the fly—define new columns without storing them. This makes .select()
perfect for testing transformations before you commit them.
When you use .select(), you’re creating a query that doesn’t execute
until you call .collect(). You must use .collect() to execute the
query and return results—nothing is stored in your table. If you want to
collect only the first few rows, use .head(n) instead of .collect()
to test on a subset before processing your full dataset. Once satisfied,
use .add_computed_column() with the same expression to persist results
permanently.
For more on this workflow, see Get fast feedback on
transformations.
See also