Files
gallery 092cc6de54 工具: 新增拍立得边框生成与相册上传脚本
tools/polaroid.py          批量套 Instax/Polaroid 边框并加中文题字(几何取自真机规格,
                           题字支持中文数字、超长自动缩字、手写抖动、做旧效果)
tools/upload_to_gallery.sh 把成片上传到相册服务器(暂存区校验后才入库、默认不覆盖、
                           mtime 按 EXIF 拍摄时间设置)
2026-09-14 14:28:16 +08:00

916 lines
44 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""批量为照片套拍立得 / Instax 边框并加文字。
设计原则:确定性、可重跑、绝不碰照片像素。同样的输入 + 同样的参数 → 逐字节一致的输出。
几何全部来自真机胶片规格(毫米),不是拍脑袋的百分比,可选:
instax_mini 54x86 底片 / 46x62 画面(3:4 竖版)
instax_wide 108x86 底片 / 99x62 画面(宽画幅)
instax_square 72x86 底片 / 62x62 画面
polaroid600 88x107 底片 / 79x79 画面(正方形)
画面区四角一律直角(真机就是直角),想要圆角用 --inner-radius。
--film 默认 "instax_mini,instax_wide",即「竖排用 Mini、横拍用 Wide」。
这两张胶片的基准方向都跟拍摄方向一致,所以宽边永远在下、题字永远横排,
相当于竖着拿 Mini 相机、平着拿 Wide 相机。
--film 只写一个规格时,宽边朝向回到真机物理:拍摄方向与胶片基准一致则
宽边在下,反了就转 90° 让宽边落在左侧(横拍照片的题字会跟着转 90°)。
用法:
python tools/polaroid.py photos -o out
python tools/polaroid.py photos -o out --film polaroid600 --hand
python tools/polaroid.py photos -o out --captions captions.tsv
python tools/polaroid.py photos -o out --flat # 关掉做旧,纯白平边框
字体默认用 tools/ 里自带的罗西硬笔柳体(方正,Version 1.00);
用 --font 指定别的字体,或者直接把 .ttf 丢进 tools/ 也会被自动认到。
"""
from __future__ import annotations
import argparse
import csv
import math
import random
import re
import sys
import zlib
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from PIL import Image, ImageChops, ImageColor, ImageDraw, ImageFilter, ImageFont, ImageOps
# ── 真机胶片规格 ──────────────────────────────────────────────────────────
# 尺寸单位 mm,取自各胶片的公开规格。三个细边(左/右/上)在所有这些胶片上
# 都是等宽的,宽边(下巴)在基准朝向下位于底部,所以只需要这四个数。
#
# 注意:Instant 胶片的画面区是直角,没有圆角(实物和规格图都是),
# 所以圆角一律为 0;真想要圆角用 --inner-radius 自己加。
#
# NAME: 底片宽, 底片高, 画面宽, 画面高, 画面区圆角
FILMS: dict[str, tuple[float, float, float, float, float]] = {
"instax_mini": (54, 86, 46, 62, 0.0),
"instax_wide": (108, 86, 99, 62, 0.0),
"instax_square": (72, 86, 62, 62, 0.0),
"polaroid600": (88, 107, 79, 79, 0.0),
}
DEFAULT_BORDER = "#f2f2f2" # 边框底色:中性灰白(原来是偏暖的奶白 #f7f4ec)
# 默认:竖排用 Mini、横拍用 Wide。两张胶片的基准方向鄽与拍摄方向一致,
# 于是宽边永远落在下方(文字不用转 90°),相当于「竖着拿 Mini、平着拿 Wide」。
DEFAULT_FILM = "instax_mini,instax_wide"
# 手写感排序:随工具自带的手写体 > 系统里的中文手写体
HERE = Path(__file__).resolve().parent
BUNDLED_FONT = "fd0176cfb4dce3888188b7f89e5dfd5b.ttf" # 罗西硬笔柳体
FONT_CANDIDATES = list(dict.fromkeys([
str(HERE / BUNDLED_FONT),
*[str(p) for p in sorted(HERE.glob("*.tt[fc]"))], # 你往 tools/ 里放别的字体也能自动认
"C:/Windows/Fonts/STXINGKA.TTF", # 华文行楷
"C:/Windows/Fonts/FZSTK.TTF", # 方正舒体
"C:/Windows/Fonts/simkai.ttf", # 楷体
"C:/Windows/Fonts/STXINWEI.TTF", # 华文新魏
"C:/Windows/Fonts/STKAITI.TTF", # 华文楷体
"/System/Library/Fonts/Supplemental/Kaiti.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"C:/Windows/Fonts/msyh.ttc", # 兜底:雅黑(无手写感但不缺字)
]))
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
EXIF_ORIENTATION = 274
EXIF_DATETIME_ORIGINAL = 36867
EXIF_DATETIME = 306
CHIN_SIDES = ("bottom", "left", "right", "top")
# ── 基本工具 ──────────────────────────────────────────────────────────────
def pick_font(custom: str | None) -> str:
if custom:
return custom
for path in FONT_CANDIDATES:
if Path(path).exists():
return path
raise SystemExit("找不到可用字体,请用 --font 指定字体文件路径")
def load_captions(path: str | None, sep: str) -> dict[str, str]:
"""captions 文件:每行 `文件名<分隔符>文字`,# 开头是注释。"""
if not path:
return {}
raw_bytes = Path(path).read_bytes()
for enc in ("utf-8-sig", "gbk"): # 记事本另存成 ANSI 也能读
try:
content = raw_bytes.decode(enc)
break
except UnicodeDecodeError:
continue
else:
raise SystemExit(f"读不了 {path},请另存为 UTF-8")
table: dict[str, str] = {}
for raw in content.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
name, _, text = line.partition(sep)
table[Path(name.strip()).name] = text.strip()
return table
CN_DIGIT = "〇一二三四五六七八九"
def cn_number(n: int) -> str:
"""中文数词读法(不是逐位读):10→十、12→十二、20→二十、25→二十五、105→一百零五。
这就是「10 月要写成十月」而不是「一十月」的关键。
"""
if n < 0:
return "负" + cn_number(-n)
if n < 10:
return CN_DIGIT[n]
if n < 20:
return "十" + (CN_DIGIT[n - 10] if n > 10 else "")
if n < 100:
tens, rest = divmod(n, 10)
return CN_DIGIT[tens] + "十" + (CN_DIGIT[rest] if rest else "")
if n < 1000:
hundreds, rest = divmod(n, 100)
head = CN_DIGIT[hundreds] + "百"
if rest == 0:
return head
if rest < 10:
return head + "零" + cn_number(rest)
if rest < 20:
return head + "一" + cn_number(rest) # 115 → 一百一十五(不是一百十五)
return head + cn_number(rest)
thousands, rest = divmod(n, 1000)
head = CN_DIGIT[thousands] + "千"
if rest == 0:
return head
if rest < 100:
return head + "零" + cn_number(rest)
if rest < 200:
return head + "一" + cn_number(rest)
return head + cn_number(rest)
def to_cn_digits(text: str) -> str:
"""把文字里的阿拉伯数字换成中文。
年份逐位读(2026 年 → 二〇二六年),其余按数词读(10 月 → 十月)。
判别方式:紧跟「年」的按年份;或本身就是 1000~2999 的四位数(平时写 2026
就是指年份),也按年份。
"""
out, last = [], 0
for match in re.finditer(r"\d+", text):
out.append(text[last:match.start()])
digits = match.group(0)
is_year = text[match.end():match.end() + 1] == "年" or (
len(digits) == 4 and 1000 <= int(digits) <= 2999)
if is_year:
out.append("".join(CN_DIGIT[int(c)] for c in digits))
else:
out.append(cn_number(int(digits)))
last = match.end()
out.append(text[last:])
return "".join(out)
def exif_date(im: Image.Image) -> datetime | None:
try:
exif = im.getexif()
except Exception:
return None
for tags in (exif, exif.get_ifd(0x8769)):
for tag in (EXIF_DATETIME_ORIGINAL, EXIF_DATETIME):
raw = tags.get(tag)
if raw:
try:
return datetime.strptime(str(raw).strip(), "%Y:%m:%d %H:%M:%S")
except ValueError:
pass
return None
def caption_for(src: Path, im: Image.Image, args, table: dict[str, str]) -> str:
if src.name in table:
text = table[src.name]
elif args.text:
text = args.text
elif args.caption_source == "filename":
text = src.stem
else:
stamp = exif_date(im) or datetime.fromtimestamp(src.stat().st_mtime)
text = stamp.strftime(args.date_format)
return to_cn_digits(text) if args.cn_digits else text
# ── 几何:全部由胶片毫米尺寸推导 ──────────────────────────────────────────
@dataclass(frozen=True)
class Layout:
outer: tuple[int, int] # 成片画布
image_box: tuple[int, int, int, int] # 画面区(照片贴这儿)
radius: int # 画面区圆角 px
chin_px: int # 宽边宽度 px
def layout(film_key: str, chin: str, photo_size: int, inner_radius_mm: float | None = None
) -> Layout:
"""算出某个厚边朝向下画布的尺寸和画面区位置。
photo_size 是画面区「长边」的像素数,所以换成任何胶片规格,成片大小都可比。
"""
fw, fh, iw, ih, rad_mm = FILMS[film_key]
if inner_radius_mm is not None:
rad_mm = inner_radius_mm
scale = photo_size / max(iw, ih)
thin = round((fw - iw) / 2 * scale) # 左/右/上三个细边等宽
cw, ch = round(iw * scale), round(ih * scale)
chin_px = round(fh * scale) - thin - ch # 宽边 = 底片高 - 细边 - 画面高
fw_px, fh_px = round(fw * scale), round(fh * scale)
radius = round(rad_mm * scale)
if chin == "left": # 基准版顺时针转 90°
return Layout((fh_px, fw_px), (chin_px, thin, chin_px + ch, thin + cw), radius, chin_px)
if chin == "right": # 逆时针转 90°
return Layout((fh_px, fw_px), (thin, thin, thin + ch, thin + cw), radius, chin_px)
if chin == "top": # 转 180°
return Layout((fw_px, fh_px), (thin, chin_px, thin + cw, chin_px + ch), radius, chin_px)
return Layout((fw_px, fh_px), (thin, thin, thin + cw, thin + ch), radius, chin_px)
def base_layout(film_key: str, photo_size: int, inner_radius_mm: float | None = None) -> Layout:
"""基准朝向(宽边在下)的版式,画边框和文字都在这上面做。"""
return layout(film_key, "bottom", photo_size, inner_radius_mm)
def margins_mm(film_key: str, chin: str) -> tuple[float, float, float, float]:
"""四个边的理论留白(左, 上, 右, 下),单位 mm。就是个便于对照规格图的工具。"""
fw, fh, iw, ih, _ = FILMS[film_key]
thin = (fw - iw) / 2
chin_mm = fh - ih - thin
return {"bottom": (thin, thin, thin, chin_mm), "top": (thin, chin_mm, thin, thin),
"left": (chin_mm, thin, thin, thin), "right": (thin, thin, chin_mm, thin)}[chin]
def parse_films(spec: str) -> tuple[str, str]:
"""--film 可以是单个规格,也可以是「竖排规格,横拍规格」。"""
parts = [s.strip() for s in spec.split(",") if s.strip()]
for name in parts:
if name not in FILMS:
raise SystemExit(f"未知胶片规格 {name!r},可选:{', '.join(sorted(FILMS))}")
if len(parts) == 1:
return parts[0], parts[0]
if len(parts) != 2:
raise SystemExit("--film 只能是单个规格,或「竖排规格,横拍规格」两项")
return parts[0], parts[1]
def film_for(photo: tuple[int, int], films: tuple[str, str]) -> str:
"""竖排照片用第一种胶片,横拍用第二种。"""
return films[0] if photo[1] >= photo[0] else films[1]
def chin_for(photo: tuple[int, int], film_key: str, override: str) -> str:
"""真机规则:拍摄方向和胶片基准方向一致 → 宽边在下;反了 → 转 90°,宽边在左。"""
if override != "auto":
return override
fw, fh, _, _, _ = FILMS[film_key]
return "bottom" if (photo[0] > photo[1]) == (fw > fh) else "left"
# ── 遮罩 / 做旧用的场 ─────────────────────────────────────────────────────
def rounded_mask(size: tuple[int, int], radius: int, ss: int = 3) -> Image.Image:
"""圆角矩形遮罩(255 在内)。超采样再缩回来,圆角才没锯齿。"""
if radius <= 0:
return Image.new("L", size, 255)
big = Image.new("L", (size[0] * ss, size[1] * ss), 0)
ImageDraw.Draw(big).rounded_rectangle(
(0, 0, size[0] * ss - 1, size[1] * ss - 1), radius=radius * ss, fill=255)
return big.resize(size, Image.Resampling.LANCZOS)
def smooth_field(size: tuple[int, int], cell: int, seed: int) -> Image.Image:
"""低频明暗场:白噪声 → 放大插值 → 大块起伏,模拟白边受光不匀。"""
small = (max(2, size[0] // cell + 1), max(2, size[1] // cell + 1))
rng = random.Random(seed)
field = Image.new("L", small)
field.putdata([rng.randint(0, 255) for _ in range(small[0] * small[1])])
return field.resize(size, Image.Resampling.BICUBIC)
def stretch(field: Image.Image, lo: int, hi: int) -> Image.Image:
"""把噪点场的动态范围拉满到 [lo, hi]。"""
fmin, fmax = field.getextrema()
if fmax <= fmin:
return Image.new("L", field.size, (lo + hi) // 2)
scale = (hi - lo) / (fmax - fmin)
return field.point(lambda v: int(lo + (v - fmin) * scale))
def darken_by(mask: Image.Image, depth: int) -> Image.Image:
"""把「越暗越深」的遮罩映射成乘性遮罩:255 不变,最深暗 depth 级。"""
return mask.point(lambda v: 255 - (255 - v) * depth // 255)
def edge_ring(size: tuple[int, int], box, radius: int, spread: int,
depth: int) -> Image.Image:
"""沿 box(画面区矩形)**外侧**一圈柔和变暗——像是照片浮在白边之上投下的影子。
box 内部恒为 255,所以照片像素乘完一点没变。
"""
m = Image.new("L", size, 255)
d = ImageDraw.Draw(m)
d.rounded_rectangle((box[0] - spread, box[1] - spread,
box[2] + spread, box[3] + spread),
radius=radius + spread, fill=0)
d.rounded_rectangle(box, radius=radius, fill=255)
m = m.filter(ImageFilter.GaussianBlur(max(1, spread // 2)))
# 抹掉模糊渗进照片的那一两像素,保证画面区逐像素不变
ImageDraw.Draw(m).rounded_rectangle(box, radius=radius, fill=255)
return darken_by(m, depth)
def inner_shadow_field(size: tuple[int, int], box, radius: int, spread: int,
depth: int) -> Image.Image:
"""沿 box **内侧**一圈柔和变暗——边框压在照片上,影子落在画面里侧边缘。
盒外恒为 255(白边完全不受影响)。注意这个效果会压暗照片最外沿那一圈,
其余画面区仍然逐像素不变。
"""
band = Image.new("L", size, 255)
d = ImageDraw.Draw(band)
d.rounded_rectangle(box, radius=radius, fill=0)
d.rounded_rectangle((box[0] + spread, box[1] + spread,
box[2] - spread, box[3] - spread),
radius=max(0, radius - spread), fill=255)
band = band.filter(ImageFilter.GaussianBlur(max(1, spread // 2)))
inside = Image.new("L", size, 0)
ImageDraw.Draw(inside).rounded_rectangle(box, radius=radius, fill=255)
# 盒外强制 255:阴影只允许落在照片里,不污染白边
return darken_by(Image.composite(band, Image.new("L", size, 255), inside), depth)
def rim_mask(size: tuple[int, int], spread: int, depth: int) -> Image.Image:
"""画布外沿柔和变暗。
在 1/8 分辨率上做再放大:大半径高斯很贵,而这个场本来就是低频的。
spread 必须远大于窄边宽,否则在宽边那片大片空白里会看到一条暗带及其
消隐边界(曾经用 长边/40,压暗刚好在窄边宽处结束,就成了一条可见分界线)。
"""
ss = 8
small = (max(4, size[0] // ss), max(4, size[1] // ss))
s = max(2, spread // ss)
m = Image.new("L", small, 0)
ImageDraw.Draw(m).rounded_rectangle(
(s, s, small[0] - s - 1, small[1] - s - 1), radius=s, fill=255)
m = m.filter(ImageFilter.GaussianBlur(s))
return darken_by(m.resize(size, Image.Resampling.BILINEAR), depth)
def multiply_rgb(frame: Image.Image, field: Image.Image) -> Image.Image:
out = frame.copy()
out.paste(ImageChops.multiply(frame.convert("RGB"),
Image.merge("RGB", (field, field, field))), (0, 0))
return out
def apply_signed(frame: Image.Image, field: Image.Image) -> None:
"""把一张 L 场(128 为中点)当作有符号偏移加到画面上:正的加、负的减、均值不动。"""
pos = field.point(lambda v: max(0, v - 128))
neg = field.point(lambda v: max(0, 128 - v))
frame.paste(ImageChops.subtract(
ImageChops.add(frame.convert("RGB"), Image.merge("RGB", (pos, pos, pos))),
Image.merge("RGB", (neg, neg, neg))), (0, 0))
def stripe_field(size: tuple[int, int], angle: float, period: int, amount: int,
width: float = 0.07) -> Image.Image:
"""方向性条纹场(L 图,128 为中点)。
先画横条纹再整体旋转,比逐像素算投影便宜得多,而且不会有拼接缝。
每个周期里只有「线心」那一条被压暗,其余保持底色 —— 所以看上去是
细灰线,而不是宽灰带。(早先做成「宽灰底+细亮脊」,视觉上就是粗灰带。)
amount 是线心的压暗级数,width 是线宽(高斯 σ 占周期的比例,越小线越细)。
angle 就是「条纹线本身的方向」,角度从水平顺时针增大:
0=水平(—),45=左上→右下(\),90=垂直(|),135=左下→右上(/),180 回到水平。
注意 PIL 的 rotate 在 y 向下的坐标系里视觉上是反的,所以这里取负号,
这样参数值等于实测方向(已用对角线方差验证),否则你没法自查。
"""
diag = int(math.hypot(*size)) + 2
prof = Image.new("L", (diag, diag), 128)
draw = ImageDraw.Draw(prof)
sigma = max(0.35, period * width)
k = 2 * (sigma / period) ** 2
for y in range(diag):
t = (y % period) / period - 0.5
line = math.exp(-(t * t) / k)
draw.line((0, y, diag, y), fill=max(0, 128 - int(amount * line)))
rotated = prof.rotate(-angle, resample=Image.Resampling.BILINEAR)
left, top = (diag - size[0]) // 2, (diag - size[1]) // 2
return rotated.crop((left, top, left + size[0], top + size[1]))
# 强度换算成灰度级数:这几个数字决定「看得见但不过分」的量级
STRIPE_LEVELS = 40
def add_outer_shadow(frame: Image.Image, args) -> Image.Image:
"""在胶片外侧留出一圈底色(并投影)。
阴影必须有比它亮的背景才看得见,所以底色是黑的时候投影实际不可见
(等于只留一圈黑框)。底色用 --outer-bg 改,透明用 --outer-bg none(需 png)。
"""
long_side = max(frame.size)
margin = round(args.shadow_margin * long_side)
blur = max(2, round(0.012 * long_side))
offset = round(0.008 * long_side)
strength = int(255 * args.shadow)
bg = args.outer_bg.strip().lower()
transparent = bg in ("none", "transparent")
if transparent and args.format != "png":
transparent, bg = False, "#ffffff" # JPEG 没有 alpha,退回白底
if margin <= 0:
return frame
canvas = Image.new("RGBA", (frame.width + 2 * margin, frame.height + 2 * margin),
(0, 0, 0, 0) if transparent else ImageColor.getrgb(bg) + (255,))
silhouette = Image.new("L", canvas.size, 0)
silhouette.paste(frame.getchannel("A"), (margin, margin + offset))
shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
shadow.putalpha(silhouette.filter(ImageFilter.GaussianBlur(blur))
.point(lambda v: v * strength // 255))
out = Image.alpha_composite(canvas, shadow)
out.paste(frame, (margin, margin), frame)
return out
def apply_paper(frame: Image.Image, seed: int, args) -> None:
"""白边上的真实纸感:受光不匀 → 外缘变暗 → 纸纹颗粒 → 条纹 → 灰点。"""
size = frame.size
long_side = max(size)
if args.light > 0:
blotch = stretch(smooth_field(size, max(8, long_side // 8), seed),
255 - round(18 * args.light), 255)
frame.paste(multiply_rgb(frame, blotch), (0, 0))
if args.rim > 0:
# 外缘略暗,但要极缓:扩散半径取长边/10(远大于窄边),深度也只有几级
rim = rim_mask(size, max(8, long_side // 10), round(5 * args.rim))
frame.paste(multiply_rgb(frame, rim), (0, 0))
if args.texture > 0:
apply_signed(frame, Image.effect_noise(size, 10.0)
.point(lambda v: (v - 128) * max(1, round(9 * args.texture)) // 127 + 128))
if args.stripe > 0:
# 边框表面的方向性细纹(默认 45°),period 按长边比例换算
period = max(3, round(args.stripe_period * long_side))
apply_signed(frame, stripe_field(size, args.stripe_angle, period,
round(STRIPE_LEVELS * args.stripe),
args.stripe_width))
if args.dust > 0:
add_dust(frame, random.Random(seed ^ 0xD057), args.dust)
def add_dust(frame: Image.Image, rng: random.Random, amount: float) -> None:
"""真底片上总有几个灰点和短纤维。"""
draw = ImageDraw.Draw(frame, "RGBA")
w, h = frame.size
for _ in range(int(amount * 40)):
x, y = rng.randrange(w), rng.randrange(h)
if rng.random() < 0.75:
alpha = rng.randint(8, 26)
draw.point((x, y), fill=(120, 115, 105, alpha))
if rng.random() < 0.4:
draw.point((x + 1, y), fill=(120, 115, 105, alpha // 2))
else:
length = rng.randint(2, 7)
draw.line((x, y, x + length, y + rng.randint(-1, 1)),
fill=(150, 145, 135, rng.randint(6, 16)))
# ── 画面处理 ──────────────────────────────────────────────────────────────
def fit_photo(im: Image.Image, area: tuple[int, int], mode: str,
gravity: str) -> Image.Image:
"""把照片放进 area 大小的画面区(crop=裁满,fit=整张保留)。"""
aw, ah = area
if mode == "crop":
scale = max(aw / im.width, ah / im.height)
new = (max(aw, round(im.width * scale)), max(ah, round(im.height * scale)))
im = im.resize(new, Image.Resampling.LANCZOS)
dx, dy = (new[0] - aw) // 2, (new[1] - ah) // 2
if gravity.startswith("top"):
dy = 0
elif gravity.startswith("bottom"):
dy = new[1] - ah
if gravity.endswith("left"):
dx = 0
elif gravity.endswith("right"):
dx = new[0] - aw
return im.crop((dx, dy, dx + aw, dy + ah))
scale = min(aw / im.width, ah / im.height)
return im.resize((max(1, round(im.width * scale)), max(1, round(im.height * scale))),
Image.Resampling.LANCZOS)
def draw_caption(canvas: Image.Image, caption: str, font: ImageFont.FreeTypeFont,
x: int, center_y: int, color, hand: bool, rng: random.Random) -> None:
"""在 canvas 上写一行字。hand=True 时逐字微旋转 + 微抖动,模拟手写。
纵向按「墨迹」而不是「行框」居中:不同字体的 ascent/descent 差很多,
按行框居中的话换个字体就得手调 --text-y。
"""
draw = ImageDraw.Draw(canvas)
as_, ds = font.getmetrics()
bbox = font.getbbox(caption) or (0, 0, 0, as_ + ds)
top = center_y - (bbox[1] + bbox[3]) // 2
if not hand:
draw.text((x, top), caption, font=font, fill=color)
return
jitter = max(1.0, font.size * 0.02)
pen = float(x)
for ch in caption:
w = font.getlength(ch)
pad = int(font.size * 0.4)
tile = Image.new("RGBA", (int(w) + pad * 2, as_ + ds + pad * 2), (0, 0, 0, 0))
ImageDraw.Draw(tile).text((pad, pad), ch, font=font, fill=color)
tile = tile.rotate(rng.uniform(-2.2, 2.2), resample=Image.Resampling.BICUBIC)
canvas.paste(tile, (int(pen) - pad + int(rng.uniform(-jitter, jitter)),
top - pad + int(rng.uniform(-jitter, jitter))), tile)
pen += w + rng.uniform(-jitter * 0.4, jitter * 0.4)
def make_film(im: Image.Image, caption: str, args, font_path: str, seed: int,
chin: str, film: str) -> Image.Image:
base = base_layout(film, args.photo_size, args.inner_radius)
final = layout(film, chin, args.photo_size, args.inner_radius)
# 先在基准朝向(宽边在下)上画边框和文字,转框不会把照片转歪
frame = Image.new("RGB", base.outer, ImageColor.getrgb(args.border_color))
apply_paper(frame, seed, args)
if caption:
indent = round(args.photo_size * args.text_indent)
start_x = base.image_box[0] + indent # 与画面区左缘对齐,再缩进一点
size_px = max(12, round(args.photo_size * args.font_ratio))
font = ImageFont.truetype(font_path, size_px)
if args.fit_text:
# 有些手写体(比如硬笔柳体)数字是全角,长题字会冲出胶片,这里自动缩到能放下。
# 可用长度必须从真实起点算起:漏掉画面区左边距就会让字贴到胶片边上。
avail = base.outer[0] - start_x - indent
need = font.getlength(caption)
if need > avail > 0:
font = ImageFont.truetype(font_path, max(12, int(size_px * avail / need)))
draw_caption(frame, caption, font, start_x,
base.outer[1] - base.chin_px + round(base.chin_px * args.text_y),
args.text_color, args.hand, random.Random(seed))
if chin == "left":
frame = frame.transpose(Image.Transpose.ROTATE_270) # 顺时针 90°
elif chin == "right":
frame = frame.transpose(Image.Transpose.ROTATE_90)
elif chin == "top":
frame = frame.transpose(Image.Transpose.ROTATE_180)
area = (final.image_box[2] - final.image_box[0], final.image_box[3] - final.image_box[1])
inner = fit_photo(im, area, args.mode, args.gravity)
photo = inner.convert("RGBA")
photo.putalpha(rounded_mask(inner.size, final.radius))
canvas = frame.convert("RGBA")
canvas.paste(photo, (final.image_box[0] + (area[0] - inner.width) // 2,
final.image_box[1] + (area[1] - inner.height) // 2), photo)
frame = canvas.convert("RGB")
# 照片实际占的矩形——接缝和黑内框都跟着它,不跟画面区
box = (final.image_box[0] + (area[0] - inner.width) // 2,
final.image_box[1] + (area[1] - inner.height) // 2,
final.image_box[0] + (area[0] + inner.width) // 2,
final.image_box[1] + (area[1] + inner.height) // 2)
spread = args.edge_spread or max(2, max(frame.size) // 300)
if args.edge > 0 and args.edge_side in ("in", "both"):
# 边框压在照片上,影子落在画面里侧
frame = multiply_rgb(frame, inner_shadow_field(
frame.size, box, final.radius, spread, round(40 * args.edge)))
if args.edge > 0 and args.edge_side in ("out", "both"):
# 接缝必须跟着「照片」而不是「画面区」:fit 时两者不重合,
# 绕画面区画会在白边上浮出一个假边框
frame = multiply_rgb(frame, edge_ring(frame.size, box, final.radius, spread,
round(40 * args.edge)))
if args.inner_line > 0:
# 画面区外沿的 1~2px 黑框。画在照片外侧,所以照片像素一点不变。
w = args.inner_line
ImageDraw.Draw(frame).rounded_rectangle(
(box[0] - w, box[1] - w, box[2] + w - 1, box[3] + w - 1),
radius=final.radius + w, outline=ImageColor.getrgb(args.inner_line_color),
width=w)
out = frame.convert("RGBA")
if args.radius > 0 and args.format == "png":
out.putalpha(rounded_mask(out.size, args.radius))
if args.shadow > 0:
out = add_outer_shadow(out, args)
return out
# ── 单文件流水线 ──────────────────────────────────────────────────────────
def process(src: Path, dst_dir: Path, args, table: dict[str, str], font_path: str) -> dict:
with Image.open(src) as raw:
# 关键:先按 EXIF 方向摆正,否则竖拍照片会躺着出锅
im = ImageOps.exif_transpose(raw).convert("RGB")
exif = raw.getexif()
icc = raw.info.get("icc_profile")
caption = caption_for(src, raw, args, table)
# 不能拿内置 hash() 当种子:它每个进程都会加盐,会让 --hand 不可重跑
seed = zlib.crc32(src.name.encode()) & 0xFFFF
film = film_for(im.size, args.films)
chin = chin_for(im.size, film, args.chin)
framed = make_film(im, caption, args, font_path, seed, chin, film)
dst = dst_dir / (src.stem + "." + args.format)
dst.parent.mkdir(parents=True, exist_ok=True)
save_kw: dict = {"quality": args.quality, "subsampling": 0, "optimize": True}
if args.keep_metadata and exif:
exif[EXIF_ORIENTATION] = 1 # 已经摆正过了
save_kw["exif"] = exif.tobytes()
if icc and args.keep_metadata:
save_kw["icc_profile"] = icc # 丢了 ICC,调色后的颜色会整体偏
if args.format in ("jpg", "jpeg"):
framed.convert("RGB").save(dst, "JPEG", progressive=True, **save_kw)
else:
framed.save(dst, "PNG")
return {"src": str(src), "out": str(dst), "caption": caption,
"film": film, "chin": chin, "size": f"{framed.width}x{framed.height}"}
def too_small(size: tuple[int, int], area: tuple[int, int], mode: str) -> bool:
"""这张源图放进画面区会不会被放大(放大就一定发虚)。"""
w, h = size
aw, ah = area
scale = max(aw / w, ah / h) if mode == "crop" else min(aw / w, ah / h)
return scale > 1.0
def excluded(path: Path, root: Path, patterns: list[str]) -> bool:
"""相对路径 / 文件名 / 任一层目录名命中 glob 就算排除。"""
import fnmatch
rel = path.relative_to(root)
names = [path.name, str(rel).replace("\\", "/")] + [p.name for p in rel.parents if p.name]
return any(fnmatch.fnmatch(n, pat) for pat in patterns for n in names)
def main() -> int:
p = argparse.ArgumentParser(description="批量套拍立得 / Instax 边框 + 文字")
p.add_argument("input", help="输入目录(递归)或单个文件")
p.add_argument("-o", "--out", required=True, help="输出目录")
p.add_argument("--text", help="所有照片用同一句文字")
p.add_argument("--captions", help="逐张文字表:文件名<分隔符>文字")
p.add_argument("--sep", default="\t", help="captions 分隔符,默认 Tab")
p.add_argument("--caption-source", choices=["exif", "filename"], default="exif",
help="没给文字时从哪取(默认 exif 拍摄时间)")
p.add_argument("--cn-digits", action="store_true",
help="把题字里的阿拉伯数字换成中文:年份逐位读(2026→二〇二六),"
"其余按数词读(10 月→十月、25 日→二十五日)")
p.add_argument("--date-format", default="%Y.%m.%d",
help="EXIF 日期格式,默认 %%Y.%%m.%%d")
p.add_argument("--film", choices=None, default=DEFAULT_FILM,
help=f"胶片规格,默认 {DEFAULT_FILM}(竖排用第一个、横拍用第二个);"
f"也可以只写一个,可选:{', '.join(sorted(FILMS))}")
p.add_argument("--photo-size", type=int, default=1200,
help="画面区长边 px,默认 1200")
p.add_argument("--mode", choices=["crop", "fit"], default="crop",
help="crop=裁满画面区(默认,真机就是这样);fit=整张保留")
p.add_argument("--crop", dest="mode", action="store_const", const="crop",
help="同 --mode crop")
p.add_argument("--fit", dest="mode", action="store_const", const="fit",
help="同 --mode fit")
p.add_argument("--gravity", default="center",
choices=["center", "top", "bottom", "left", "right",
"top-left", "top-right", "bottom-left", "bottom-right"],
help="crop 时保留哪个部位(人像常需要 top)")
p.add_argument("--chin", choices=["auto", *CHIN_SIDES], default="auto",
help="宽边在哪一侧。auto=跟随拍摄方向,与真机一致")
p.add_argument("--font", help="字体文件,默认自动选中文手写体")
p.add_argument("--font-ratio", type=float, default=0.058,
help="字号 / 画面区长边")
p.add_argument("--text-indent", type=float, default=0.03,
help="文字起点 / 画面区长边")
p.add_argument("--text-y", type=float, default=0.42,
help="文字在宽边内的位置:0=靠画面,1=靠外缘")
p.add_argument("--text-color", default="#2b2b2b", help="文字颜色,默认近黑")
p.add_argument("--hand", action="store_true", help="手写抖动效果")
p.add_argument("--no-fit-text", dest="fit_text", action="store_false",
help="题字超出宽边时不要自动缩字(默认会自动缩)")
# ── 做旧:默认开,想要纯白平边框就用 --flat ──
p.add_argument("--flat", action="store_true",
help="关掉全部做旧效果(受光不匀/纸纹/灰点/条纹/接缝/投影),只留底色和文字")
p.add_argument("--light", type=float, default=0.5, help="白边受光不匀强度 0~1")
p.add_argument("--rim", type=float, default=0.5,
help="外缘变暗强度 0~1,默认 0.5(很轻)。若在宽边上还看得到暗带,给 0")
p.add_argument("--texture", "--grain", dest="texture", type=float, default=0.35,
help="纸纹颗粒强度 0~1")
p.add_argument("--dust", type=float, default=0.3, help="灰点/纤维强度 0~1")
p.add_argument("--edge", type=float, default=0.6,
help="画面区边缘的阴影强度 0~1,默认 0.6")
p.add_argument("--edge-spread", type=int, default=0, metavar="PX",
help="阴影向内(或向外)延伸多少 px,默认 0=自动(长边/300 ≈ 4px)")
p.add_argument("--edge-side", choices=["in", "out", "both"], default="in",
help="阴影落在哪侧:in=照片内侧(默认,边框压在照片上的感觉);"
"out=白边一侧;both=两侧都加")
p.add_argument("--inner-line", type=int, default=1, metavar="PX",
help="画面区内侧的黑框宽度 px,默认 1;0=不要")
p.add_argument("--inner-line-color", default="#000000", help="黑框颜色,默认纯黑")
p.add_argument("--stripe", type=float, default=0.3,
help="条纹强度 0~1,默认 0.3;0=不要")
p.add_argument("--stripe-angle", type=float, default=45.0, metavar="DEG",
help="条纹线方向(度,从水平顺时针):0=水平,45=左上→右下(\),"
"90=垂直,135=左下→右上(/)。默认 45")
p.add_argument("--stripe-period", type=float, default=0.008,
help="条纹间距 / 长边,默认 0.008(Wide 约 10px)")
p.add_argument("--stripe-width", type=float, default=0.07,
help="灰线粗细 / 间距,默认 0.07(越小线越细)")
p.add_argument("--shadow", type=float, default=0.0,
metavar="AMOUNT", help="胶片外侧投影强度 0~1,默认 0(关);"
"要开得同时给 --shadow-margin")
p.add_argument("--shadow-margin", type=float, default=0.0,
help="外侧底色圈宽度 / 长边,默认 0(不加外圈)")
p.add_argument("--outer-bg", "--shadow-bg", dest="outer_bg", default="#ffffff",
metavar="COLOR", help="外圈底色,仅在 --shadow-margin > 0 时有意义。"
"阴影只有在比它亮的底色上才看得见;给 none 则放进"
" alpha(需 --format png)")
p.add_argument("--inner-radius", type=float, default=None, metavar="MM",
help="画面区圆角半径 mm。默认 0:真机画面区是直角")
p.add_argument("--border-color", default=DEFAULT_BORDER, help="边框底色,默认灰白 #f2f2f2")
p.add_argument("--radius", type=int, default=0, help="外框圆角(仅 png 输出生效)")
p.add_argument("--format", default="jpg", choices=["jpg", "jpeg", "png"])
p.add_argument("--quality", type=int, default=92)
p.add_argument("--strip-metadata", dest="keep_metadata", action="store_false",
help="剥掉 EXIF/GPS/ICC(默认保留拍摄时间、GPS 和色彩配置)")
p.add_argument("--skip-small", action="store_true",
help="跳过会被放大而发虚的源图(边框模板、图标混进来时的保险)")
p.add_argument("--skip-existing", action="store_true", help="输出已存在就跳过")
p.add_argument("--exclude", action="append", default=[], metavar="GLOB",
help="跳过匹配的输入(可重复)。匹配相对路径、文件名或任一层目录名,\n"
"例如 --exclude 'processed' --exclude 'polaroid_preview/*'")
p.add_argument("--limit", type=int, help="只处理前 N 张,用来快速试参数")
p.add_argument("--manifest", help="把处理结果写成 CSV")
p.add_argument("--dry-run", action="store_true", help="只列出会做什么")
p.add_argument("-j", "--jobs", type=int, default=0, help="并行进程数,0=CPU 核数")
args = p.parse_args()
if args.flat:
args.light = args.rim = args.texture = args.dust = args.edge = args.stripe = 0.0
args.shadow = 0.0
# 不劫持边框底色:--flat 只表示「不做旧」,颜色照 --border-color
src_root = Path(args.input).resolve()
dst_root = Path(args.out).resolve()
if src_root.is_dir() and dst_root == src_root:
print("[错误] 输出目录不能等于输入目录,会覆盖原图", file=sys.stderr)
return 2
if dst_root.exists() and not dst_root.is_dir():
print(f"[错误] 输出路径已存在且不是目录:{dst_root}", file=sys.stderr)
return 2
if src_root.is_file():
files = [src_root]
else:
files = sorted(f for f in src_root.rglob("*")
if f.suffix.lower() in IMAGE_EXT and f.is_file())
# 输出目录如果在输入目录里(比如 -o photos/polaroid),别把刚生成的图
# 再当成原图处理一遍——套两层框的下场很难看
files = [f for f in files if dst_root not in f.resolve().parents]
if args.exclude:
files = [f for f in files if not excluded(f, src_root, args.exclude)]
if args.limit:
files = files[:args.limit]
files = [f for f in files if not (args.skip_existing
and (dst_root / (f.stem + "." + args.format)).exists())]
if not files:
print("没有需要处理的文件")
return 0
font_path = pick_font(args.font)
table = load_captions(args.captions, args.sep)
base = src_root if src_root.is_dir() else src_root.parent
for f in files:
(dst_root / f.relative_to(base)).parent.mkdir(parents=True, exist_ok=True)
args.films = parse_films(args.film)
fw, fh, iw, ih, _ = FILMS[args.films[0]]
print(f"共 {len(files)} 张 → {dst_root}")
if args.films[0] == args.films[1]:
fw, fh, iw, ih, _ = FILMS[args.films[0]]
print(f"胶片 {args.films[0]}(底片 {fw:g}x{fh:g} 画面 {iw:g}x{ih:g}mm)"
f" / 画面区长边 {args.photo_size}px / {args.mode} / 宽边 {args.chin}")
else:
print(f"胶片 竖排={args.films[0]} 横拍={args.films[1]}"
f" / 画面区长边 {args.photo_size}px / {args.mode} / 宽边 {args.chin}")
print(f"字体 {font_path}")
if args.films[0] == args.films[1]:
for side in CHIN_SIDES:
gaps = " / ".join(f"{v:.1f}" for v in margins_mm(args.films[0], side))
print(f" 宽边在{side:6s} → 左/上/右/下留白 {gaps} mm")
else:
for film in args.films:
fw, fh, iw, ih, _ = FILMS[film]
gaps = " / ".join(f"{v:.1f}" for v in margins_mm(film, "bottom"))
lay_b = layout(film, "bottom", args.photo_size)
lay_l = layout(film, "left", args.photo_size)
print(f" {film:14s} 底片 {fw:g}x{fh:g} 画面 {iw:g}x{ih:g}mm;"
f"宽边在下留白 {gaps}mm → {lay_b.outer[0]}x{lay_b.outer[1]},"
f"宽边在左 → {lay_l.outer[0]}x{lay_l.outer[1]}")
# 预检:会被放大的源图一定发虚(边框模板、图标、成品图都是这类)
small = []
for f in files:
try:
with Image.open(f) as probe:
size = probe.size
except Exception: # noqa: BLE001
continue
film = film_for(size, args.films)
area = layout(film, chin_for(size, film, args.chin),
args.photo_size, args.inner_radius).image_box
if too_small(size, (area[2] - area[0], area[3] - area[1]), args.mode):
small.append((f, size))
if small:
if args.skip_small:
drop = {f for f, _ in small}
files = [f for f in files if f not in drop]
print(f"已跳过 {len(small)} 张放进画面区会被放大的图")
else:
print(f"注意:{len(small)} 张放进画面区会被放大而发虚"
"(加 --skip-small 可跳过):")
for f, (w, h) in small[:5]:
print(f" {f.name} {w}x{h}")
if len(small) > 5:
print(f" ...还有 {len(small) - 5} 张")
if not files:
print("跳完就没剩什么了")
return 0
if args.dry_run:
for f in files:
print(" ", f.relative_to(base))
return 0
jobs = max(1, args.jobs or (__import__("os").cpu_count() or 4))
results, failed = [], []
with ProcessPoolExecutor(max_workers=jobs) as pool:
futures = {pool.submit(process, f, dst_root / f.relative_to(base).parent,
args, table, font_path): f for f in files}
for i, fut in enumerate(as_completed(futures), 1):
src = futures[fut]
try:
results.append(fut.result())
except Exception as exc: # noqa: BLE001
failed.append((src, exc))
print(f" [跳过] {src.name}: {exc}", file=sys.stderr)
if i % 25 == 0 or i == len(files):
print(f" {i}/{len(files)}")
if results:
went: dict[tuple[str, str, str], int] = {}
for r in results:
key = (r["film"], r["chin"], r["size"])
went[key] = went.get(key, 0) + 1
print("完成 %d 张:" % len(results)
+ ";".join(f"{k[0]} 宽边在{k[1]} {k[2]} x{v}张" for k, v in sorted(went.items())))
if args.manifest:
with open(args.manifest, "w", newline="", encoding="utf-8-sig") as fh:
writer = csv.DictWriter(fh, fieldnames=["src", "out", "film", "chin",
"caption", "size"])
writer.writeheader()
writer.writerows(sorted(results, key=lambda r: r["src"]))
print(f"清单写到 {args.manifest}")
if failed:
print(f"失败 {len(failed)} 张", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())