maomomo-article-writer/scripts/maomomo_tile_watermark.py

228 lines
8.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env python3
"""为 MAOMOMO 配图叠加 45° 平铺半透明水印。
默认水印MAOMOMO.COM45° 对角线平铺;自动对比色,浅底黑字、深底白字。
用法:
uv run python scripts/maomomo_tile_watermark.py --input assets --output watermarked_assets
uv run python scripts/maomomo_tile_watermark.py --input assets/example.png --output assets/example-watermarked.png
说明:
脚本使用 Pillow。本机 Python 没有 Pillow 且存在 uv 时,会自动用
`uv run --with pillow python ...` 重启一次,不需要把依赖写入项目。
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Iterable, Iterator, Optional, Tuple, Any
SUPPORTED_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
def die(message: str, code: int = 1) -> None:
print(f"错误:{message}", file=sys.stderr)
raise SystemExit(code)
def ensure_pillow() -> Tuple[Any, Any, Any]:
try:
from PIL import Image, ImageDraw, ImageFont
return Image, ImageDraw, ImageFont
except ModuleNotFoundError as exc:
if exc.name != "PIL":
raise
uv = shutil.which("uv")
if uv and os.environ.get("MAOMOMO_WATERMARK_REEXEC") != "1":
env = os.environ.copy()
env["MAOMOMO_WATERMARK_REEXEC"] = "1"
cmd = [uv, "run", "--with", "pillow>=10.0.0", "python", str(Path(__file__).resolve()), *sys.argv[1:]]
raise SystemExit(subprocess.call(cmd, env=env))
die("缺少 Pillow。请使用 `uv run python scripts/maomomo_tile_watermark.py ...` 运行,或安装 Pillow。")
def parse_color(value: str) -> Tuple[int, int, int]:
text = value.strip()
if text.lower() == "auto":
die("auto color must be resolved after reading each image")
if text.startswith("#"):
text = text[1:]
if len(text) != 6:
die("颜色必须是 auto 或 #RRGGBB例如 #000000 / #FFFFFF")
try:
return tuple(int(text[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
except ValueError:
die("颜色必须是有效十六进制,例如 #000000 / #FFFFFF")
def auto_contrast_color(image_path: Path, threshold: float = 150.0) -> Tuple[int, int, int]:
Image, _, _ = ensure_pillow()
with Image.open(image_path) as src:
r, g, b = src.convert("RGB").resize((1, 1)).getpixel((0, 0))
luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
return (0, 0, 0) if luminance >= threshold else (255, 255, 255)
def load_font(ImageFont: Any, font_path: Optional[str], font_size: int) -> Any:
candidates = []
if font_path:
candidates.append(Path(font_path).expanduser())
candidates.extend(
Path(p)
for p in [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
]
)
for candidate in candidates:
if candidate.exists():
try:
return ImageFont.truetype(str(candidate), font_size)
except OSError:
continue
return ImageFont.load_default()
def iter_images(input_path: Path) -> Iterator[Path]:
if input_path.is_file():
if input_path.suffix.lower() not in SUPPORTED_EXTS:
die(f"不支持的图片格式:{input_path}")
yield input_path
return
if not input_path.is_dir():
die(f"输入不存在:{input_path}")
for path in sorted(input_path.rglob("*")):
if path.is_file() and path.suffix.lower() in SUPPORTED_EXTS:
yield path
def resolve_output(input_root: Path, image_path: Path, output_root: Path) -> Path:
if input_root.is_file():
return output_root
rel = image_path.relative_to(input_root)
return output_root / rel
def make_tile(
Image: Any,
ImageDraw: Any,
text: str,
font: Any,
color: Tuple[int, int, int],
alpha: int,
angle: float,
padding: int,
) -> Any:
dummy = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
draw = ImageDraw.Draw(dummy)
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
tile_w = max(1, text_w + padding * 2)
tile_h = max(1, text_h + padding * 2)
tile = Image.new("RGBA", (tile_w, tile_h), (0, 0, 0, 0))
tile_draw = ImageDraw.Draw(tile)
tile_draw.text((padding, padding), text, font=font, fill=(*color, alpha))
return tile.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC)
def apply_watermark(
image_path: Path,
output_path: Path,
*,
text: str,
angle: float,
alpha: int,
color: Tuple[int, int, int],
font_size: int,
font_path: Optional[str],
spacing: int,
quality: int,
) -> None:
if alpha < 0 or alpha > 255:
die("alpha 必须在 0-255 之间")
if spacing < 0:
die("spacing 不能为负数")
Image, ImageDraw, ImageFont = ensure_pillow()
font = load_font(ImageFont, font_path, font_size)
with Image.open(image_path) as src:
base = src.convert("RGBA")
width, height = base.size
diagonal = int((width * width + height * height) ** 0.5)
tile = make_tile(Image, ImageDraw, text, font, color, alpha, angle, spacing)
overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0))
step_x = max(1, tile.width + spacing)
step_y = max(1, tile.height + spacing)
start_x = -diagonal
start_y = -diagonal
for y in range(start_y, height + diagonal, step_y):
for x in range(start_x, width + diagonal, step_x):
overlay.alpha_composite(tile, (x, y))
out = Image.alpha_composite(base, overlay)
output_path.parent.mkdir(parents=True, exist_ok=True)
suffix = output_path.suffix.lower()
if suffix in {".jpg", ".jpeg"}:
out = out.convert("RGB")
out.save(output_path, quality=quality, optimize=True)
elif suffix == ".webp":
out.save(output_path, quality=quality, method=6)
else:
out.save(output_path)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="为图片叠加 MAOMOMO.COM 45° 平铺半透明水印。")
parser.add_argument("--input", required=True, help="输入图片文件或目录。")
parser.add_argument("--output", required=True, help="输出图片文件或目录。目录模式会保持相对路径。")
parser.add_argument("--text", default="MAOMOMO.COM", help="水印文字,默认 MAOMOMO.COM。")
parser.add_argument("--angle", type=float, default=45.0, help="水印旋转角度,默认 45。")
parser.add_argument("--alpha", type=int, default=70, help="透明度 0-255默认 70。")
parser.add_argument("--color", default="auto", help="水印颜色,默认 auto浅底黑字、深底白字也可指定 #000000 / #FFFFFF。")
parser.add_argument("--font-size", type=int, default=50, help="字体大小,默认 50。")
parser.add_argument("--font", default=None, help="可选字体路径。")
parser.add_argument("--spacing", type=int, default=28, help="水印间距 / 内边距,默认 28目标是每张图至少可见 2 个完整 MAOMOMO.COM。")
parser.add_argument("--quality", type=int, default=95, help="JPG/WEBP 输出质量,默认 95。")
return parser
def main(argv: Optional[Iterable[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(list(argv) if argv is not None else None)
input_path = Path(args.input).expanduser().resolve()
output_path = Path(args.output).expanduser().resolve()
images = list(iter_images(input_path))
if not images:
die(f"没有找到支持的图片:{input_path}")
if input_path.is_dir() and output_path.exists() and output_path.is_file():
die("输入为目录时,输出必须是目录路径")
results = []
for image_path in images:
target = resolve_output(input_path, image_path, output_path)
apply_watermark(
image_path,
target,
text=args.text,
angle=args.angle,
alpha=args.alpha,
color=auto_contrast_color(image_path) if str(args.color).lower() == "auto" else parse_color(args.color),
font_size=args.font_size,
font_path=args.font,
spacing=args.spacing,
quality=args.quality,
)
results.append((image_path, target))
for src, dst in results:
print(f"watermarked: {src} -> {dst}")
return 0
if __name__ == "__main__":
raise SystemExit(main())