Preparing your learning space...
100% through Mini Projects tutorials
A program that adds text watermarks to images. You will learn how to open images with Pillow (PIL), draw text on them, control font size and position, adjust transparency with alpha blending, and save the output. Watermarking is essential for photographers, artists, and businesses that share images online.
Watermarking places a semi-transparent text or logo over an image to identify the owner and prevent unauthorized use. This program takes an image file, adds a user-specified text watermark (e.g., "© 2026 Your Name") at a chosen position and transparency level, and saves the result as a new file.
Where this is used in the real world: Photographers watermark portfolio images before uploading to social media. Stock photo sites watermark previews. Businesses watermark internal documents. Bloggers watermark featured images.
Knowledge needed
os.listdir, file paths)Software needed
pip install Pillow)WatermarkImages/ │ ├── main.py # The watermark program ├── requirements.txt # Pillow ├── input_images/ # Place source images here │ └── photo.jpg └── output_images/ # Watermarked images saved here
main.py – The complete programrequirements.txt – Lists Pillow as dependencyinput_images/ – Drop your images here before runningoutput_images/ – Watermarked versions are saved here, named [original]_watermarked.[ext]mkdir WatermarkImages
cd WatermarkImages
mkdir input_images output_images
Place a test image named photo.jpg inside input_images/. You can use any JPG or PNG file.
Create main.py.
Pillow is the modern fork of the Python Imaging Library (PIL). It handles opening, manipulating, and saving images.
pip install Pillow
Save to requirements.txt:
Pillow
Start by loading an image and printing its properties.
from PIL import Image
img = Image.open("input_images/photo.jpg")
print(f"Format: {img.format}")
print(f"Size: {img.size}")
print(f"Mode: {img.mode}")
img.show()
Image.open(path) — Opens the image file and returns an Image object. The file is not fully decoded into memory until pixels are accessed.
img.format — The file format: "JPEG", "PNG", "BMP", etc.
img.size — A tuple (width, height) in pixels. (1920, 1080) means 1920px wide, 1080px tall.
img.mode — The pixel format. Common values:
| Mode | Meaning | Used by |
|---|---|---|
"RGB" | Red, Green, Blue (24-bit, no transparency) | JPEG photos |
"RGBA" | Red, Green, Blue, Alpha (32-bit, with transparency) | PNG with transparency |
"L" | Grayscale (8-bit) | Black and white images |
Output when run:
Format: JPEG
Size: (1920, 1080)
Mode: RGB
Watermarks need transparency. JPEG images are RGB (no alpha channel). PNG images can be RGBA (has alpha channel). To overlay a semi-transparent watermark, both layers must be RGBA.
if img.mode != "RGBA":
img = img.convert("RGBA")
RGBA pixel breakdown:
A single RGBA pixel: (R, G, B, A)
│ │ │ │
Red (0-255) ─────────┘ │ │ │
Green (0-255) ──────────┘ │ │
Blue (0-255) ──────────────┘ │
Alpha (0-255) ────────────────┘
255 = fully opaque
0 = fully transparent
Create a blank transparent layer the same size as the original image. The text will be drawn onto this layer, then blended with the original.
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
Image.new("RGBA", size, color) — Creates a new image with the given mode, size, and fill color.
(0, 0, 0, 0) — RGBA black with alpha = 0. The fourth value (0) means fully transparent. The overlay starts invisible. The watermark text will become visible where we draw it.
Use ImageDraw to draw text onto the overlay.
from PIL import Image, ImageDraw, ImageFont
draw = ImageDraw.Draw(overlay)
text = "© 2026 Your Name"
position = (50, 50)
text_color = (255, 255, 255, 180) # White, alpha=180 (≈70% opaque)
draw.text(position, text, fill=text_color)
ImageDraw.Draw(overlay) — Returns a Draw object that can add text, lines, shapes, etc. to the overlay image. The overlay is the "canvas."
draw.text(xy, text, fill=color) — Draws the text string starting at position (50, 50) from the top-left corner.
(255, 255, 255, 180) — White text with alpha = 180 (out of 255). 255 = fully opaque. 0 = invisible. 180 ≈ 70% opacity — visible but the photo shows through.
Pillow can use TrueType fonts (.ttf) for professional-looking text. Without specifying a font, Pillow uses a tiny default (usually 10px — too small for a watermark).
try:
font = ImageFont.truetype("arial.ttf", 48)
except IOError:
font = ImageFont.load_default()
print("Arial not found, using default font.")
ImageFont.truetype("arial.ttf", 48) — Loads a TrueType font file at 48 points. On Windows, arial.ttf is in the system fonts folder and available by name. On macOS, use /System/Library/Fonts/Helvetica.ttc. On Linux, install fonts-freefont-ttt and use "FreeSans.ttf".
except IOError — Catches the case where the font file is missing. ImageFont.load_default() returns Pillow's built-in bitmap font (tiny, ~10px, not scalable).
To position the watermark (e.g., "bottom-right"), we need to know how many pixels wide and tall the rendered text will be.
def get_text_size(draw, text, font):
bbox = draw.textbbox((0, 0), text, font=font)
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
return width, height
draw.textbbox((0, 0), text, font=font) — Returns a bounding box (left, top, right, bottom) of the text if drawn at position (0, 0). The bounding box accounts for the font's ascenders and descenders (e.g., the tail of "g" hangs below the baseline).
Example for text "Watermark" at font size 48:
bbox = (0, -2, 240, 50)
│ │ │ │
│ │ │ └── bottom (y coordinate of lowest pixel)
│ │ └────── right (x coordinate of rightmost pixel)
│ └─────────── top (negative means above the baseline)
└─────────────── left (always 0 when drawn at (0,0))
width = 240 - 0 = 240 pixels
height = 50 - (-2) = 52 pixels
Calculate x, y coordinates based on the image size, text size, and user's choice.
def get_position(img_size, text_size, pos_name, padding=20):
iw, ih = img_size
tw, th = text_size
positions = {
"top-left": (padding, padding),
"top-right": (iw - tw - padding, padding),
"bottom-left": (padding, ih - th - padding),
"bottom-right": (iw - tw - padding, ih - th - padding),
"center": ((iw - tw) // 2, (ih - th) // 2)
}
x, y = positions.get(pos_name, (padding, padding))
# Clamp to keep text within image bounds
if iw > tw:
x = max(padding, min(x, iw - tw - padding))
if ih > th:
y = max(padding, min(y, ih - th - padding))
return x, y
Position calculation diagram for bottom-right:
┌──────────────────────────────────┐ ──
│ │ │
│ │ │
│ │ ih
│ │ │
│ ┌───────┐│ │
│ padding ───▶│ Water ││ │
│ 20px │ mark ││ │
│ └───────┘│ │
│ ←─ tw ──→│ │
└──────────────────────────────────┘ ──
←──────────── iw ──────────────────→
Position X = iw - tw - padding = right edge minus text width minus 20px
Position Y = ih - th - padding = bottom edge minus text height minus 20px
The user specifies opacity as a percentage (0-100). Convert it to an alpha value (0-255).
def get_text_color(opacity_percent):
alpha = int(255 * opacity_percent / 100)
alpha = max(0, min(255, alpha)) # clamp to valid range
return (255, 255, 255, alpha) # white text with alpha
Conversion table:
| User says | Calculation | Alpha | Visual effect |
|---|---|---|---|
| 100% | 255 × 100/100 = 255 | 255 | Fully opaque — covers the image |
| 50% | 255 × 50/100 = 127 | 127 | Balanced — image visible through text |
| 25% | 255 × 25/100 = 63 | 63 | Subtle — barely visible |
| 10% | 255 × 10/100 = 25 | 25 | Very faint — watermark still detectable |
| 0% | 0 | 0 | Invisible — text hidden |
max(0, min(255, alpha)) — Clamps the value. If the user types "200", alpha would be 510, which exceeds the maximum 255. Clamping prevents errors.
Merge the watermark overlay onto the original using alpha compositing.
watermarked = Image.alpha_composite(img.convert("RGBA"), overlay)
Image.alpha_composite(background, overlay) — Blends the overlay onto the background using each pixel's alpha channel. Where the overlay is transparent (alpha=0), the background shows through. Where the overlay is opaque (alpha=255), the watermark covers the image. In between, they blend.
Visual representation of compositing:
Original pixel: (100, 150, 200, 255) ← solid background
Overlay pixel: (255, 255, 255, 127) ← white watermark at 50% opacity
↓ alpha blending
Result pixel: blend of both colors, weighted by alpha
On light image backgrounds, white text can be hard to read. A dark drop shadow creates contrast.
# Draw shadow (offset by 2px)
shadow_color = (0, 0, 0, alpha) # black with same alpha as text
draw.text((position[0] + 2, position[1] + 2), text, fill=shadow_color, font=font)
# Draw main text on top
draw.text(position, text, fill=text_color, font=font)
Why this works: The shadow is drawn first, shifted 2 pixels right and 2 pixels down. The main text is drawn on top at the exact position. The 2px offset creates a subtle shadow effect that works on any background:
On white background: shadow is barely visible, text visible
On dark background: shadow blends in, white text visible
On mixed background: shadow provides edge contrast
Save the result. JPEG must be converted back to RGB first (no alpha support).
if output_path.lower().endswith((".jpg", ".jpeg")):
watermarked = watermarked.convert("RGB")
watermarked.save(output_path)
print(f"Watermarked image saved: {output_path}")
JPEG vs PNG for watermarked images:
| Format | Pros | Cons |
|---|---|---|
| PNG | Lossless, preserves transparency, exact colors | Larger file size |
| JPEG | Smaller file, widely compatible | Lossy compression, no transparency |
watermarked.convert("RGB") — Strips the alpha channel. Required before saving as JPEG because the JPEG format does not support transparency. If we tried to save an RGBA image as JPEG, Pillow raises an OSError.
Here is the core watermarking logic:
import os
from PIL import Image, ImageDraw, ImageFont
def get_text_size(draw, text, font):
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
def get_position(img_size, text_size, pos_name, padding=20):
iw, ih = img_size
tw, th = text_size
positions = {
"top-left": (padding, padding),
"top-right": (iw - tw - padding, padding),
"bottom-left": (padding, ih - th - padding),
"bottom-right": (iw - tw - padding, ih - th - padding),
"center": ((iw - tw) // 2, (ih - th) // 2)
}
x, y = positions.get(pos_name, (padding, padding))
# Clamp to keep text within image bounds
if iw > tw:
x = max(padding, min(x, iw - tw - padding))
if ih > th:
y = max(padding, min(y, ih - th - padding))
return x, y
def add_watermark(input_path, output_path, text, pos_name, opacity, font_size):
img = Image.open(input_path).convert("RGBA")
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
try:
font = ImageFont.truetype("arial.ttf", font_size)
except IOError:
font = ImageFont.load_default()
text_size = get_text_size(draw, text, font)
position = get_position(img.size, text_size, pos_name)
alpha = max(0, min(255, int(255 * opacity / 100)))
# Draw shadow
shadow_color = (0, 0, 0, alpha)
draw.text((position[0] + 2, position[1] + 2), text, fill=shadow_color, font=font)
# Draw main text
text_color = (255, 255, 255, alpha)
draw.text(position, text, fill=text_color, font=font)
watermarked = Image.alpha_composite(img, overlay)
if output_path.lower().endswith((".jpg", ".jpeg")):
watermarked = watermarked.convert("RGB")
watermarked.save(output_path)
print(f"Watermarked: {output_path}")
import os
from PIL import Image, ImageDraw, ImageFont
def get_text_size(draw, text, font):
"""Return (width, height) of the rendered text."""
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
def get_position(img_size, text_size, pos_name):
"""Return (x, y) for the watermark based on position choice."""
iw, ih = img_size
tw, th = text_size
padding = 20
positions = {
"top-left": (padding, padding),
"top-right": (iw - tw - padding, padding),
"bottom-left": (padding, ih - th - padding),
"bottom-right": (iw - tw - padding, ih - th - padding),
"center": ((iw - tw) // 2, (ih - th) // 2)
}
x, y = positions.get(pos_name, (padding, padding))
# Clamp to keep text within image bounds
if iw > tw:
x = max(padding, min(x, iw - tw - padding))
if ih > th:
y = max(padding, min(y, ih - th - padding))
return x, y
def add_watermark(input_path, output_path, text, pos_name, opacity, font_size):
"""Apply text watermark to an image and save the result."""
img = Image.open(input_path).convert("RGBA")
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Load font (with fallback)
try:
font = ImageFont.truetype("arial.ttf", font_size)
except IOError:
print("Font not found, using default (very small).")
font = ImageFont.load_default()
# Calculate position
text_size = get_text_size(draw, text, font)
position = get_position(img.size, text_size, pos_name)
# Convert opacity percentage to alpha (0-255)
alpha = int(255 * opacity / 100)
alpha = max(0, min(255, alpha))
# Draw shadow (offset by 2px for readability)
shadow_color = (0, 0, 0, alpha)
draw.text((position[0] + 2, position[1] + 2), text, fill=shadow_color, font=font)
# Draw main text
text_color = (255, 255, 255, alpha)
draw.text(position, text, fill=text_color, font=font)
# Composite overlay onto original
watermarked = Image.alpha_composite(img, overlay)
# Save (convert to RGB for JPEG)
if output_path.lower().endswith((".jpg", ".jpeg")):
watermarked = watermarked.convert("RGB")
watermarked.save(output_path)
print(f"Watermarked image saved: {output_path}")
def list_images(folder):
"""Return a list of image filenames in the given folder."""
images = []
for f in os.listdir(folder):
if f.lower().endswith((".png", ".jpg", ".jpeg", ".bmp")):
images.append(f)
return images
def main():
print("=== Watermark Images ===\n")
# Show available images
images = list_images("input_images")
if not images:
print("No images found in input_images/ folder.")
print("Place an image there and run again.")
return
print("Available images:")
for i, name in enumerate(images, 1):
print(f" {i}. {name}")
# Image selection
try:
choice = int(input("\nSelect image number: ")) - 1
input_file = os.path.join("input_images", images[choice])
except (ValueError, IndexError):
print("Invalid choice.")
return
# Watermark text
text = input("Watermark text: ")
if not text.strip():
text = "© Your Name"
print(f'Using default text: "{text}"')
# Position
print("\nPosition options: top-left, top-right, bottom-left, bottom-right, center")
pos = input("Position (default: bottom-right): ").lower() or "bottom-right"
# Opacity
try:
opacity = int(input("Opacity in % (1-100, default 50): ") or "50")
except ValueError:
opacity = 50
opacity = max(1, min(100, opacity))
# Font size
try:
font_size = int(input("Font size in px (default 48): ") or "48")
except ValueError:
font_size = 48
font_size = max(10, min(200, font_size))
# Output path
base, ext = os.path.splitext(images[choice])
output_file = os.path.join("output_images", f"{base}_watermarked{ext}")
# Apply watermark
add_watermark(input_file, output_file, text, pos, opacity, font_size)
# Show result
result = Image.open(output_file)
result.show()
if __name__ == "__main__":
main()
Screenshot Suggestion: Side-by-side comparison: original photo and watermarked photo with "© 2026 Your Name" at bottom-right, 50% opacity, showing the difference.
Run the file:
python main.py
Test 1 – Basic watermark
Place photo.jpg in input_images/. Select it.
Watermark text: "© 2026"
Position: bottom-right
Opacity: 50
Font size: 48
Expected: Output image with semi-transparent watermark in the bottom-right corner.
Test 2 – Top-left position
Position: top-left
Expected: Watermark appears at top-left with 20px padding.
Test 3 – High opacity (90%)
Opacity: 90
Expected: Watermark is nearly solid white, background barely visible through it.
Test 4 – Low opacity (10%)
Opacity: 10
Expected: Watermark is very faint, barely visible.
Test 5 – Center position
Position: center
Expected: Watermark centered both horizontally and vertically.
Test 6 – No images in folder
Run with empty input_images/ folder.
Expected: "No images found in input_images/ folder."
Test 7 – Invalid inputs (non-numeric opacity)
Type "abc" at opacity prompt.
Expected: Falls back to default 50.
FileNotFoundError: [Errno 2] No such file or directory
Trigger: The folder "input_images/" does not exist or is empty.
Why: `Image.open()` or `os.listdir()` cannot find the path.
Fix: Run `mkdir input_images` and place an image file in it.
IOError: cannot open font resource
Trigger: `ImageFont.truetype("arial.ttf", 48)` cannot find arial.ttf.
Why: The font is not installed on your system or the path is wrong.
Fix: The `try/except` block catches this and falls back to `load_default()`.
For better fonts, find your system's font path:
- Windows: "C:/Windows/Fonts/arial.ttf"
- macOS: "/System/Library/Fonts/Helvetica.ttc"
- Linux: "/usr/share/fonts/truetype/freefont/FreeSans.ttf"
AttributeError: module 'PIL.Image' has no attribute 'alpha_composite'
Trigger: Old version of Pillow (before 3.0.0).
Why: `alpha_composite()` was added in Pillow 3.0.
Fix: Update Pillow: `pip install --upgrade Pillow`
ValueError: images do not match
Trigger: `alpha_composite()` called with different-sized images.
Why: The overlay and original have different dimensions.
Fix: Always create overlay with `img.size`: `Image.new("RGBA", img.size, ...)`
SystemError: tile cannot extend outside image
Trigger: Watermark position puts the text partially off-screen.
Why: E.g., "bottom-right" of an image that is smaller than the text + padding.
Fix: Add clamping in `get_position()`:
`x = min(x, max(0, iw - tw - padding))`
`y = min(y, max(0, ih - th - padding))`
Image.paste(logo, position, logo))datetime.now()Logo Watermark – Instead of text, overlay a semi-transparent PNG logo at the corner. Use Image.paste(logo, position, logo) where the logo is its own transparency mask. Resize the logo to 10% of the image width first.
Batch Processing – Add a mode that watermarks every image in input_images/ with the same text, position, opacity, and font size settings, saving each to output_images/ without asking per-image.
Diagonal Watermark – Create a large rotated text watermark by creating a text image with ImageDraw, rotating it with img.rotate(45, expand=True), and pasting it centered over the original. Use alpha compositing to blend.
Metadata Preservation – Before saving, copy EXIF data from the original to the watermarked image: watermarked.info["exif"] = img.info.get("exif", b""). This preserves camera data (aperture, ISO, date, GPS) in the output.
Watermark Preview – After building the watermarked image, show it with img.show() and ask "Save? (yes/no)" before writing to disk. This lets the user adjust opacity and position without generating garbage files.
Save your progress and earn XP for completing tutorials.
Keep learning
Technology
Python
Lesson group
Mini Projects
Progress
100% complete