Image Watermarking Tool (python, written by Codex)
envgap__codex__python-t1-20
Written by a coding agent; not on GitHubWritten 2026-03-03
01 / FAILURE SIGNATURE
As the study recorded it
None
Not a benchmark task.
- The project already builds and runs before the fix, so there is nothing to repair.
02 / ENVIRONMENT RECIPE
- Base commit
Not freshly verified- Manifest
requirements.txt- Reproduce
Awaiting issue-specific recipe- Run under trace
Awaiting a meaningful runtime command
03 / TASK AND FAILURE
codex/python-t1 #20 · read the task the agent was given
Codex wrote this python project from the task below. It installed and ran on a clean Ubuntu 22.04 machine as written. Task given to the agent: TASK: Image Watermarking Tool Write a program that adds text and image watermarks to photographs with configurable positioning, opacity, tiling, and batch processing capabilities. FUNCTIONAL REQUIREMENTS: - Accept an image file path as a command-line argument - Support text watermarks via --text flag with configurable font size (--font-size, default 24), color (--color, hex format, default #FFFFFF), and font family (--font, default: a bundled or system sans-serif font) - Support image overlay watermarks via --watermark flag (accepts a PNG file path with transparency) - Support configurable position via --position flag: center, top-left, top-right, bottom-left, bottom-right, or custom coordinates (x,y) - Support configurable opacity via --opacity flag (0.0 to 1.0, default 0.3) - Support tiled/repeated watermark mode via --tile flag that repeats the watermark across the entire image with configurable spacing (--spacing, default 100 pixels) and rotation angle (--angle, default -30 degrees) - Support adding a semi-transparent background band behind text watermarks for better readability via --background-band flag - Support batch watermarking of all images in a directory via --batch flag - Preserve original image quality and metadata in the output - Print processing details to console: input file, watermark type, position, opacity, output file, processing time - Save watermarked images to a path specified by --output flag (default: original name with _watermarked suffix) - If no input is given, generate a sample 1024x768 photograph-like image, apply both a text watermark and a tiled watermark, then save both versions to demonstrate the capabilities - Handle errors: missing font files, invalid watermark images, unsupported image formats, images too small for the watermark Create a complete Python project for a clean Ubuntu 22.04 machine with only Python 3.10+ installed. Include: - Source code - requirements.txt with all dependencies (direct and transitive) pinned to exact versions - README.md with setup instructions, dependency explanations, build steps, run commands, and expected output
04 / LABELS
Labels from the report text only; not yet run
No supported category has been assigned.
Label rules and the text that matched
[]
05 / FILES
The project as the agent wrote it
3 files, exactly as written, before any repair.
README.md
# Image Watermarking Tool (Python) Adds text and PNG image watermarks with configurable position, opacity, tiling, and batch processing. ## Requirements - Ubuntu 22.04 - Python 3.10+ ## Dependency (Pinned) - `Pillow==10.4.0` ## Setup ```bash python -m venv .venv source .venv/bin/activate pip install -r requirements.txt ``` ## Run ```bash python src/main.py input.jpg --text "CONFIDENTIAL" --font-size 36 --color "#FFFFFF" --position bottom-right --opacity 0.3 python src/main.py input.jpg --watermark overlay.png --position center --opacity 0.5 python src/main.py input.jpg --text "PREVIEW" --tile --spacing 120 --angle -30 --opacity 0.2 python src/main.py ./images --batch --text "ARCHIVE" --position top-left --output ./watermarked python src/main.py ``` ## Notes - Default output is `<original>_watermarked.<ext>`. - `--position` supports named positions and custom `x,y`. - `--background-band` adds a semi-transparent strip behind text. - No-arg mode generates `sample_photo_1024x768.png` and writes text/tiled watermark demos.
requirements.txt
Pillow==10.4.0
src/main.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import time
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFont
SUPPORTED = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"}
NAMED_POSITIONS = {"center", "top-left", "top-right", "bottom-left", "bottom-right"}
MAX_PIXELS = 120_000_000
def parse_color(value: str) -> str:
v = value.strip()
if len(v) != 7 or not v.startswith("#"):
raise ValueError("Invalid --color. Use #RRGGBB.")
int(v[1:], 16)
return v
def parse_opacity(value: float | None) -> float:
v = 0.3 if value is None else float(value)
if v < 0.0 or v > 1.0:
raise ValueError("Invalid --opacity. Use a value between 0.0 and 1.0.")
return v
def parse_position(raw: str | None, base_w: int, base_h: int, wm_w: int, wm_h: int) -> tuple[int, int]:
value = (raw or "bottom-right").strip().lower()
pad = 24
if value in NAMED_POSITIONS:
if value == "center":
return max(0, (base_w - wm_w) // 2), max(0, (base_h - wm_h) // 2)
if value == "top-left":
return pad, pad
if value == "top-right":
return max(0, base_w - wm_w - pad), pad
if value == "bottom-left":
return pad, max(0, base_h - wm_h - pad)
return max(0, base_w - wm_w - pad), max(0, base_h - wm_h - pad)
if "," not in value:
raise ValueError("Invalid --position. Use center/top-left/top-right/bottom-left/bottom-right or x,y.")
x_str, y_str = value.split(",", 1)
return int(x_str), int(y_str)
def load_font(font_option: str | None, font_size: int) -> ImageFont.ImageFont:
if font_size <= 0:
raise ValueError("Invalid --font-size.")
if font_option:
candidate = Path(font_option)
if candidate.exists():
return ImageFont.truetype(str(candidate), font_size)
if "/" in font_option or "\\" in font_option:
raise FileNotFoundError(f"Missing font file: {candidate.resolve()}")
try:
return ImageFont.truetype(font_option, font_size)
except Exception:
return ImageFont.load_default()
try:
return ImageFont.truetype("DejaVuSans.ttf", font_size)
except Exception:
return ImageFont.load_default()
def apply_opacity(image: Image.Image, opacity: float) -> Image.Image:
if opacity >= 1.0:
return image
alpha = image.split()[-1]
alpha = alpha.point(lambda px: int(px * opacity))
out = image.copy()
out.putalpha(alpha)
return out
def create_text_watermark(text: str, args: argparse.Namespace) -> Image.Image:
color = parse_color(args.color or "#FFFFFF")
font_size = int(args.font_size or 24)
font = load_font(args.font, font_size)
probe = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
pdraw = ImageDraw.Draw(probe)
bbox = pdraw.textbbox((0, 0), text, font=font)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
pad_x = max(12, font_size // 2)
pad_y = max(10, font_size // 3)
width = max(80, tw + pad_x * 2)
height = max(40, th + pad_y * 2)
wm = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(wm)
if args.background_band:
band_top = max(0, (height - th) // 2 - 6)
band_bottom = min(height, band_top + th + 12)
draw.rectangle((0, band_top, width, band_bottom), fill=(0, 0, 0, 110))
draw.text((pad_x, pad_y), text, fill=color, font=font)
return wm
def create_image_watermark(path_value: str) -> Image.Image:
p = Path(path_value).resolve()
if not p.exists():
raise FileNotFoundError(f"Watermark image not found: {p}")
if p.suffix.lower() != ".png":
raise ValueError("Image watermark must be a PNG with transparency.")
try:
return Image.open(p).convert("RGBA")
except Exception as exc:
raise ValueError(f"Invalid watermark image: {p}") from exc
def output_path_for(input_path: Path, output_opt: str | None, batch_mode: bool) -> Path:
if not output_opt:
return input_path.with_name(f"{input_path.stem}_watermarked{input_path.suffix}")
out = Path(output_opt).resolve()
if batch_mode:
out.mkdir(parents=True, exist_ok=True)
return out / f"{input_path.stem}_watermarked{input_path.suffix}"
if out.exists() and out.is_dir():
return out / f"{input_path.stem}_watermarked{input_path.suffix}"
return out
def tile_overlay(base_size: tuple[int, int], wm: Image.Image, spacing: int, angle: float) -> Image.Image:
if spacing < 0:
raise ValueError("Invalid --spacing. Must be >= 0.")
rotated = wm.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC)
rw, rh = rotated.size
step_x = max(1, rw + spacing)
step_y = max(1, rh + spacing)
overlay = Image.new("RGBA", base_size, (0, 0, 0, 0))
for y in range(-rh, base_size[1] + rh, step_y):
for x in range(-rw, base_size[0] + rw, step_x):
overlay.alpha_composite(rotated, (x, y))
return overlay
def watermark_image(input_path: Path, args: argparse.Namespace, batch_mode: bool = False) -> dict:
started = time.perf_counter()
if input_path.suffix.lower() not in SUPPORTED:
raise ValueError(f"Unsupported image format: {input_path}")
with Image.open(input_path) as src:
if src.width * src.height > MAX_PIXELS:
raise MemoryError(f"Image too large for safe processing: {input_path}")
if args.text:
wm = create_text_watermark(args.text, args)
wm_type = "text"
elif args.watermark:
wm = create_image_watermark(args.watermark)
wm_type = "image"
else:
raise ValueError("Provide either --text or --watermark.")
opacity = parse_opacity(args.opacity)
tile = bool(args.tile)
if not tile and (wm.width > src.width or wm.height > src.height):
raise ValueError("Image too small for watermark.")
src_rgba = src.convert("RGBA")
wm = apply_opacity(wm, opacity)
if tile:
spacing = int(args.spacing or 100)
angle = float(args.angle if args.angle is not None else -30)
overlay = tile_overlay(src_rgba.size, wm, spacing, angle)
out_rgba = Image.alpha_composite(src_rgba, overlay)
position_desc = "tile"
else:
overlay = Image.new("RGBA", src_rgba.size, (0, 0, 0, 0))
x, y = parse_position(args.position, src_rgba.width, src_rgba.height, wm.width, wm.height)
overlay.alpha_composite(wm, (x, y))
out_rgba = Image.alpha_composite(src_rgba, overlay)
position_desc = args.position or "bottom-right"
out_path = output_path_for(input_path, args.output, batch_mode)
out_path.parent.mkdir(parents=True, exist_ok=True)
save_kwargs: dict = {}
if "exif" in src.info:
save_kwargs["exif"] = src.info["exif"]
if "icc_profile" in src.info:
save_kwargs["icc_profile"] = src.info["icc_profile"]
ext = out_path.suffix.lower()
if ext in {".jpg", ".jpeg"}:
out_rgb = out_rgba.convert("RGB")
save_kwargs["quality"] = 95
save_kwargs["optimize"] = True
out_rgb.save(out_path, **save_kwargs)
else:
out_rgba.save(out_path, **save_kwargs)
ms = (time.perf_counter() - started) * 1000.0
return {
"input": str(input_path.resolve()),
"watermark_type": wm_type,
"position": position_desc,
"opacity": round(opacity, 3),
"output": str(out_path),
"processing_ms": round(ms, 2),
}
def print_result(row: dict) -> None:
print(f"Input file: {row['input']}")
print(f"Watermark type: {row['watermark_type']}")
print(f"Position: {row['position']}")
print(f"Opacity: {row['opacity']}")
print(f"Output file: {row['output']}")
print(f"Processing time: {row['processing_ms']} ms")
print()
def list_images(folder: Path) -> list[Path]:
return [p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in SUPPORTED]
def create_sample_image() -> Path:
out = Path("sample_photo_1024x768.png").resolve()
width, height = 1024, 768
img = Image.new("RGB", (width, height))
draw = ImageDraw.Draw(img)
for y in range(height):
t = y / max(1, height - 1)
r = int((1 - t) * 111 + t * 33)
g = int((1 - t) * 177 + t * 95)
b = int((1 - t) * 252 + t * 145)
draw.line((0, y, width, y), fill=(r, g, b))
for x in range(0, width, 28):
draw.line((x, 0, x, height), fill=(255, 255, 255, 45))
for y in range(0, height, 28):
draw.line((0, y, width, y), fill=(0, 0, 0, 35))
draw.ellipse((770, 60, 920, 210), fill=(255, 245, 190))
draw.rectangle((0, 500, width, height), fill=(43, 121, 95))
img.save(out, format="PNG")
return out
def run_demo() -> int:
sample = create_sample_image()
args_text = argparse.Namespace(
text="CONFIDENTIAL",
watermark=None,
font_size=42,
color="#FFFFFF",
font=None,
position="bottom-right",
opacity=0.35,
tile=False,
spacing=100,
angle=-30,
background_band=True,
batch=False,
output=str(Path("sample_text_watermarked.png").resolve()),
)
print_result(watermark_image(sample, args_text, batch_mode=False))
args_tile = argparse.Namespace(
text="PREVIEW",
watermark=None,
font_size=36,
color="#FFFFFF",
font=None,
position="center",
opacity=0.2,
tile=True,
spacing=120,
angle=-30,
background_band=False,
batch=False,
output=str(Path("sample_tiled_watermarked.png").resolve()),
)
print_result(watermark_image(sample, args_tile, batch_mode=False))
return 0
def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Image Watermarking Tool")
p.add_argument("input", nargs="?")
p.add_argument("--text")
p.add_argument("--font-size", type=int, default=24)
p.add_argument("--color", default="#FFFFFF")
p.add_argument("--font")
p.add_argument("--watermark")
p.add_argument("--position", default="bottom-right")
p.add_argument("--opacity", type=float, default=0.3)
p.add_argument("--tile", action="store_true")
p.add_argument("--spacing", type=int, default=100)
p.add_argument("--angle", type=float, default=-30.0)
p.add_argument("--background-band", action="store_true")
p.add_argument("--batch", action="store_true")
p.add_argument("--output")
return p
def main() -> int:
args = parser().parse_args()
if not args.input and not args.batch:
return run_demo()
if not args.input:
raise ValueError("Input path required.")
input_path = Path(args.input).resolve()
if args.batch:
if not input_path.is_dir():
raise ValueError("--batch requires a directory input.")
for image_path in list_images(input_path):
print_result(watermark_image(image_path, args, batch_mode=True))
return 0
if not input_path.is_file():
raise FileNotFoundError(f"Input file not found: {input_path}")
print_result(watermark_image(input_path, args, batch_mode=False))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"Error: {exc}")
raise SystemExit(1)