工具: 新增拍立得边框生成与相册上传脚本
tools/polaroid.py 批量套 Instax/Polaroid 边框并加中文题字(几何取自真机规格,
题字支持中文数字、超长自动缩字、手写抖动、做旧效果)
tools/upload_to_gallery.sh 把成片上传到相册服务器(暂存区校验后才入库、默认不覆盖、
mtime 按 EXIF 拍摄时间设置)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# 保持 shell 脚本为 LF:本机 core.autocrlf=true,checkout 时会写成 CRLF,
|
||||
# 而 CRLF 的 shebang 在 Linux/WSL 上会变成「bad interpreter: /usr/bin/env bash」。
|
||||
tools/*.sh text eol=lf
|
||||
Executable
+915
@@ -0,0 +1,915 @@
|
||||
#!/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())
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# 把处理好的成片上传到相册服务器。
|
||||
#
|
||||
# 设计原则:先在服务器暂存区校验通过,才动相册目录;同名文件默认不覆盖。
|
||||
# 用法:
|
||||
# bash tools/upload_to_gallery.sh # 传 photos/polaroid 到 /home/gallery/photos
|
||||
# bash tools/upload_to_gallery.sh --dry-run # 只校验本地清单并打印计划,不联网
|
||||
# bash tools/upload_to_gallery.sh --dest /home/gallery/photos/polaroid # 传到子目录,绝不覆盖原图
|
||||
# bash tools/upload_to_gallery.sh --overwrite # 明确允许覆盖同名文件
|
||||
#
|
||||
# 上传完还需要在相册后台 Settings -> Scan All 触发扫描才会出现在界面里。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SRC="photos/polaroid"
|
||||
HOST="tx-ubuntu"
|
||||
DEST="/home/gallery/photos"
|
||||
MANIFEST="_sha256.txt"
|
||||
OVERWRITE=0
|
||||
DRY_RUN=0
|
||||
OWNER="gallery:gallery"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--src) SRC="$2"; shift 2 ;;
|
||||
--host) HOST="$2"; shift 2 ;;
|
||||
--dest) DEST="$2"; shift 2 ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--overwrite) OVERWRITE=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
-h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "未知参数:$1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$SRC"
|
||||
count=$(ls -1 *.jpg 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ "$count" -gt 0 ] || { echo "在 $SRC 里没找到 .jpg" >&2; exit 1; }
|
||||
|
||||
echo "== 1/6 本地校验($count 个文件)=="
|
||||
if [ -f "$MANIFEST" ]; then
|
||||
sha256sum -c "$MANIFEST"
|
||||
else
|
||||
echo "没有 $MANIFEST,现场生成一份"
|
||||
sha256sum *.jpg | tee "$MANIFEST"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 2/6 计划 =="
|
||||
echo " 源目录 $(pwd)"
|
||||
echo " 目标 $HOST:$DEST"
|
||||
echo " 同名处理 $( [ "$OVERWRITE" = 1 ] && echo '允许覆盖' || echo '不覆盖(发现同名就中止)' )"
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
echo
|
||||
echo "(--dry-run,到此为止,没有联网)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "== 3/6 连通性 =="
|
||||
if ! ssh -o BatchMode=yes -o ConnectTimeout=8 "$HOST" true 2>/dev/null; then
|
||||
{
|
||||
echo "连不上 $HOST 的 SSH。依次排查:"
|
||||
echo " 1) 当前网络是否封了出站 22 端口(酒店/办公网/部分运营商常见)。验证:"
|
||||
echo " ssh -o ConnectTimeout=8 git@github.com # 也超时就说明是本机网络封了 22"
|
||||
echo " 换手机热点或别的网络再跑。"
|
||||
echo " 2) ~/.ssh/config 里有没有这个别名,或用 --host user@ip。"
|
||||
echo " 3) 服务器是否在线:curl -sI https://gallery.fukun.net | head -1"
|
||||
} >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
stage="/tmp/pv-photos-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
echo
|
||||
echo "== 4/6 上传到暂存区 $stage =="
|
||||
ssh "$HOST" "mkdir -p '$stage'"
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -av --partial ./*.jpg "$MANIFEST" "$HOST:$stage/"
|
||||
else
|
||||
scp ./*.jpg "$MANIFEST" "$HOST:$stage/"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 5/6 服务器侧校验 =="
|
||||
ssh "$HOST" "cd '$stage' && sha256sum -c '$MANIFEST'"
|
||||
|
||||
echo
|
||||
echo "== 6/6 入库 =="
|
||||
clash=$(ssh "$HOST" "cd '$stage' && for f in *.jpg; do [ -e '$DEST'/\$f ] && echo \"\$f\"; done" || true)
|
||||
if [ -n "$clash" ]; then
|
||||
echo "以下文件在 $DEST 里已存在:"
|
||||
echo "$clash" | sed 's/^/ /'
|
||||
if [ "$OVERWRITE" != 1 ]; then
|
||||
echo
|
||||
echo "已中止,服务器暂存区保留在 $stage(可以改 --dest 传到子目录重来)。" >&2
|
||||
echo "确认要覆盖原图时加 --overwrite。" >&2
|
||||
exit 3
|
||||
fi
|
||||
echo "(--overwrite 已开启,继续覆盖)"
|
||||
fi
|
||||
# 顺序很讲究,三步都不能换:
|
||||
# 1) 先改暂存区(真实目录)属主。不能对 $DEST 做 chown -R:/home/gallery/photos 是指向
|
||||
# /data/gallery-photos 的软链接,chown -R 默认不跟随,会「退出码 0 但什么都没做」。
|
||||
# 2) 再把文件 mtime 设成 EXIF 拍摄时间。Photoview 建 media 记录时用 mtime 初始化
|
||||
# date_shot,而它的 EXIF 任务有 bug(先存库后赋值 + 已关联即提前返回)不会纠正,
|
||||
# 所以 mtime 就是相册里最终显示和排序的日期。
|
||||
# 3) 最后用 root 的 mv 移入相册(保留属主和 mtime)。
|
||||
ssh "$HOST" "sudo chown -R '$OWNER' '$stage'"
|
||||
ssh "$HOST" "for f in '$stage'/*.jpg; do sudo -u '${OWNER%%:*}' exiftool -q '-FileModifyDate<DateTimeOriginal' \"\$f\" 2>/dev/null || true; done"
|
||||
echo "暂存区文件的 mtime(应等于拍摄时间):"
|
||||
ssh "$HOST" "ls -l --time-style=+%Y-%m-%d_%H:%M '$stage'/*.jpg | awk '{print \" \" \$6, \$7}' | head -5"
|
||||
ssh "$HOST" "sudo mv '$stage'/*.jpg '$DEST'/"
|
||||
owners=$(ssh "$HOST" "ls -l '$DEST'/*.jpg | awk '{print \$3\":\"\$4}' | sort | uniq -c")
|
||||
echo "目标目录属主统计(应为 $OWNER):"
|
||||
echo "$owners" | sed 's/^/ /'
|
||||
if echo "$owners" | grep -qv "$OWNER"; then
|
||||
echo "注意:上面有不属于 $OWNER 的文件,检查一下(可能是旧文件或权限漂移)。" >&2
|
||||
fi
|
||||
ssh "$HOST" "rm -rf '$stage'"
|
||||
|
||||
echo
|
||||
echo "完成。还差一步:相册后台 Settings -> Scan All 触发扫描,才会出现在界面里。"
|
||||
echo "公开原图地址形如 https://cdn.fukun.net/gallery/photos/<文件名>"
|
||||
Reference in New Issue
Block a user