#!/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())