新增配图平铺水印功能
This commit is contained in:
Executable
+218
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""为 MAOMOMO 配图叠加 45° 平铺半透明水印。
|
||||
|
||||
默认水印:MAOMOMO.COM,白色半透明,45° 对角线平铺。
|
||||
|
||||
用法:
|
||||
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.startswith("#"):
|
||||
text = text[1:]
|
||||
if len(text) != 6:
|
||||
die("颜色必须是 #RRGGBB,例如 #FFFFFF")
|
||||
try:
|
||||
return tuple(int(text[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
|
||||
except ValueError:
|
||||
die("颜色必须是有效十六进制,例如 #FFFFFF")
|
||||
|
||||
|
||||
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=72, help="透明度 0-255,默认 72。")
|
||||
parser.add_argument("--color", default="#FFFFFF", help="水印颜色,默认 #FFFFFF。")
|
||||
parser.add_argument("--font-size", type=int, default=42, help="字体大小,默认 42。")
|
||||
parser.add_argument("--font", default=None, help="可选字体路径。")
|
||||
parser.add_argument("--spacing", type=int, default=120, help="水印间距 / 内边距,默认 120。")
|
||||
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()
|
||||
color = parse_color(args.color)
|
||||
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=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())
|
||||
Reference in New Issue
Block a user