← All tasks
pythoncodex/python-t1 #20Not a task: already works

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)