mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-10-27 13:33:43 +00:00
Add tools/watermark.py
给配图加 CyC2018 浅水印:整张图斜向平铺淡色文字,右下角加与已有配图一致的标识。加过水印的图片会写入元数据标记,重复执行时跳过;--check 只检查不修改。
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""给配图加 CyC2018 浅水印。
|
||||
|
||||
用法:
|
||||
python3 tools/watermark.py notes/pics/a.png notes/pics/b.jpg
|
||||
python3 tools/watermark.py --check notes/pics/*.png # 只检查,不修改
|
||||
|
||||
水印分两层:
|
||||
- 整张图斜向平铺一层很淡的 “CyC2018” 文字,不影响阅读;
|
||||
- 右下角加上与已有配图一致的浅灰色猫头图标和 “CyC2018”,右下角有内容时在底部补白边。
|
||||
角标大小按图片宽度自动缩放,也可以用 --scale 指定。
|
||||
|
||||
加过水印的图片会在元数据中写入标记,再次执行时直接跳过,不会重复叠加。
|
||||
GIF 动图不处理。依赖 Pillow。
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, PngImagePlugin
|
||||
|
||||
TEXT = "CyC2018"
|
||||
MARK = "Watermark: CyC2018"
|
||||
GRAY = (128, 128, 128)
|
||||
ALPHA = 0.57 # 角标:白底上约为 #B7B7B7,与已有配图的水印深浅一致
|
||||
TILE_ALPHA = 0.07 # 平铺:白底上约为 #F6F6F6,只在留意时可见
|
||||
TILE_ANGLE = 30 # 平铺文字的倾斜角度
|
||||
SS = 4 # 超采样倍数,用于抗锯齿
|
||||
|
||||
FONTS = [ # 等宽字体,与已有水印一致
|
||||
"/System/Library/Fonts/Menlo.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
"C:/Windows/Fonts/consola.ttf",
|
||||
]
|
||||
|
||||
|
||||
def _font(size):
|
||||
for path in FONTS:
|
||||
if os.path.exists(path):
|
||||
return ImageFont.truetype(path, size)
|
||||
return ImageFont.load_default(size)
|
||||
|
||||
|
||||
def _mark(scale):
|
||||
"""生成右下角水印的 alpha 蒙版。"""
|
||||
icon = round(24 * scale) * SS
|
||||
font = _font(round(19 * scale) * SS)
|
||||
left, top, right, bottom = font.getbbox(TEXT)
|
||||
gap = round(8 * scale) * SS
|
||||
w = icon + gap + right
|
||||
h = max(icon, bottom - top)
|
||||
mask = Image.new("L", (w, h), 0)
|
||||
d = ImageDraw.Draw(mask)
|
||||
|
||||
# 猫头图标:按 100×100 网格绘制后缩放
|
||||
def p(x, y):
|
||||
return (x * icon / 100, (h - icon) / 2 + y * icon / 100)
|
||||
|
||||
d.polygon([p(10, 50), p(14, 4), p(46, 26)], fill=255)
|
||||
d.polygon([p(90, 50), p(86, 4), p(54, 26)], fill=255)
|
||||
d.rounded_rectangle([p(6, 22), p(94, 96)], radius=34 * icon / 100, fill=255)
|
||||
d.rounded_rectangle([p(18, 54), p(82, 88)], radius=16 * icon / 100, fill=0)
|
||||
d.ellipse([p(31, 61), p(41, 79)], fill=255)
|
||||
d.ellipse([p(59, 61), p(69, 79)], fill=255)
|
||||
|
||||
d.text((icon + gap, (h - (bottom - top)) / 2 - top), TEXT, font=font, fill=255)
|
||||
return mask.resize((w // SS, h // SS), Image.LANCZOS)
|
||||
|
||||
|
||||
def _tiles(size, scale):
|
||||
"""生成整张图斜向平铺的文字蒙版。"""
|
||||
W, H = size
|
||||
font = _font(round(15 * scale))
|
||||
right = font.getbbox(TEXT)[2]
|
||||
dx, dy = round(right * 2.6), round(85 * scale)
|
||||
side = int((W ** 2 + H ** 2) ** 0.5) + 2 * dx
|
||||
big = Image.new("L", (side, side), 0)
|
||||
d = ImageDraw.Draw(big)
|
||||
for row, y in enumerate(range(0, side, dy)):
|
||||
for x in range(-dx + (dx // 2) * (row % 2), side, dx):
|
||||
d.text((x, y), TEXT, font=font, fill=255)
|
||||
big = big.rotate(TILE_ANGLE, resample=Image.BICUBIC)
|
||||
l, t = (side - W) // 2, (side - H) // 2
|
||||
return big.crop((l, t, l + W, t + H))
|
||||
|
||||
|
||||
def _blank(img, box):
|
||||
"""判断区域内是否基本为空白。"""
|
||||
region = img.crop(box).convert("L")
|
||||
dark = sum(1 for v in region.getdata() if v < 235)
|
||||
return dark <= region.width * region.height * 0.002
|
||||
|
||||
|
||||
def add(img, scale=None):
|
||||
"""返回加了水印的新图片。scale 默认按宽度计算,1000px 宽的图为 1。"""
|
||||
if scale is None:
|
||||
scale = min(max(img.width / 1000, 0.6), 2.0)
|
||||
img = img.convert("RGBA") if img.mode in ("P", "LA", "RGBA") else img.convert("RGB")
|
||||
mask = _mark(scale)
|
||||
mx, my = round(12 * scale), round(6 * scale)
|
||||
W, H = img.size
|
||||
box = (W - mask.width - 2 * mx, H - mask.height - 2 * my, W, H)
|
||||
if box[0] < 0 or not _blank(img, box):
|
||||
bg = (255, 255, 255, 255) if img.mode == "RGBA" else (255, 255, 255)
|
||||
padded = Image.new(img.mode, (W, H + mask.height + 2 * my), bg)
|
||||
padded.paste(img, (0, 0))
|
||||
img, H = padded, padded.height
|
||||
layer = Image.new(img.mode, img.size, GRAY + ((255,) if img.mode == "RGBA" else ()))
|
||||
img.paste(layer, (0, 0), _tiles(img.size, scale).point(lambda v: round(v * TILE_ALPHA)))
|
||||
corner = Image.new("L", img.size, 0)
|
||||
corner.paste(mask.point(lambda v: round(v * ALPHA)), (W - mask.width - mx, H - mask.height - my))
|
||||
img.paste(layer, (0, 0), corner)
|
||||
return img
|
||||
|
||||
|
||||
def marked(img):
|
||||
"""图片是否已经加过水印。"""
|
||||
value = img.info.get("Watermark") or img.info.get("comment") or b""
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8", "ignore")
|
||||
return "CyC2018" in value
|
||||
|
||||
|
||||
def save(img, path):
|
||||
"""保存图片并写入水印标记。"""
|
||||
if path.lower().endswith(".png"):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("Watermark", "CyC2018")
|
||||
img.save(path, pnginfo=info, optimize=True)
|
||||
else:
|
||||
img.convert("RGB").save(path, quality=92, comment=MARK.encode())
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="给配图加 CyC2018 浅水印")
|
||||
ap.add_argument("images", nargs="+")
|
||||
ap.add_argument("--scale", type=float, help="角标缩放,默认按图片宽度计算")
|
||||
ap.add_argument("--check", action="store_true", help="只检查是否已加水印")
|
||||
ap.add_argument("--mark-only", action="store_true", help="图片已有水印,只写入标记")
|
||||
args = ap.parse_args()
|
||||
missing = 0
|
||||
for path in args.images:
|
||||
if not path.lower().endswith((".png", ".jpg", ".jpeg")):
|
||||
print("skip (not png/jpg)", path)
|
||||
continue
|
||||
img = Image.open(path)
|
||||
if marked(img):
|
||||
print("ok", path)
|
||||
elif args.check:
|
||||
print("missing watermark", path)
|
||||
missing += 1
|
||||
elif args.mark_only:
|
||||
save(img, path)
|
||||
print("marked", path)
|
||||
else:
|
||||
save(add(img, args.scale), path)
|
||||
print("watermarked", path)
|
||||
sys.exit(1 if missing else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user