车票式旅行卡生成器
Overview
输入风景照 + 地点/时间等信息,输出车票式(登机牌式)图片。输出恒为 PNG。
票面结构(内部各比例按 9 张样张实测还原;票面宽高比与画幅为设计选择,见下):
┌────────────────────────────────┬╌╌╌┐
│ │ │
● │ 照片区(左 68.6%W) │ │ ● ← 左右缘半圆冲孔(票面高度中线,左右各一)
│ │ │
└────────────────────────────────┴───┘
● ● ● ● ● 左右两侧齿孔(半圆**内凹**,齿距 0.0558H,左右同相位)
╷
╵ ④ 虚线撕线 x = 71.62%W;其**上、下边缘各切一个三角形豁口**
(半宽 3.0%W、深 4.0%H,尖角朝票面内侧)—— 见 `references/design-spec.md` §2.1
> 两侧穿孔是"内凹半圆"、且左右同相位(见 references/design-spec.md §2.2)——这是刻意的,
> 不要"修"成外凸齿:一张纸打孔后撕成两半,两半各留一个凹半圆;按原样对接时两个半圆恰好合成
> 那个完整的圆孔("严丝合缝拼成一张纸")。外凸的半圆会彼此顶撞,做不到这一点。
> 同样,中央大冲孔左右两缘各有一个(不是只做右缘),保证左右对称。
信息区自上而下最多 7 行:首行编号 → 地名大写拼音(1~3 行大标题)→ 日期 → 中部编号 → 编号串 → 条形码。
默认只输出 5 项:No.<年> → 地名 → 日期 → 编号串 → 条形码(中部编号默认不画;中文小字已停用)。
各行在信息区中轴水平居中(不是左对齐 —— 样张实测结论,见 references/design-spec.md §3)。
字号自适应(硬约束):每行字高按"内容长度 vs 可用宽度"逐行收缩;可用宽度以信息区中轴为基准取
左右对称约束(左侧不得越过照片右缘、右侧不得越过票面留白)。地名在"单行紧排 / 2 行 / 3 行"中
择字高最大者,故长地名会自动折行而非被压成小字。实测 8 组长文本(含 西双版纳→XISHUANGBANNA
这类 13 字母)与照片净距全部 > 46px,无一压到照片。
> 「堆叠两行」是预期版式,不是缺陷(使用人已确认):QINGDAO 会渲染成 QING / DAO 两行。
> 原因是可用宽度仅 188px,单行需约 320px → 字高被迫压到约 36px(名义值的 6 折,票根会明显空);
> 折行后字高可达约 61px,观感更饱满,也接近登机牌的地名堆叠风格。不要改成强制单行。
票面尺寸:画布默认 4:3 横版(1440×1080),票面本体 2.5:1(修长造型)、占画布面积 26.4%。
尺寸取两个上限的较小者:
TW = min(0.7854 × CW, √(面积占比 × CW × CH × 票面宽高比))。
- 只有宽度上限 → 横版画布(4:3)配修长票面会过宽、留白不足;
- 只有面积上限 → 竖版画布(3:4)在 2.5:1 下会把票面撑到画布宽的 94%、左右留白消失。
取小后,任意"画幅 × 票面比例"组合都保得住四周留白。该式在样张比例(--aspect 1.7535)下与旧公式
逐项等价:34 → 848×484、11 → 1131×645、916 → 848×484。
配色策略:背景底色由风景照主色决定(主色相锁定,仅在明度/彩度上缩放);
票面走 tonal:与底色同色相、明度抬高、彩度微增,形成"同色系但有可见区别"的搭配
(实测 9 张样张纸面与底色的 |ΔH| 中位仅 8.2°,第 9 张即"浅绿纸 / 灰绿底")。墨色恒为暖褐系。
何时使用
| 场景 | 是否适用 |
|---|---|
| 用户给旅行照片,要"做成车票 / 票根 / 登机牌风格的图" | ✅ |
| 一次给多张照片(可含多组不同地点/日期),要批量出图 | ✅ |
| 需要 4:3、3:4、1:1、9:16 的打卡图 | ✅ |
| 只是要普通照片调色 / 拼图 / 加边框 | ❌ |
| 要的是海报、日历、明信片排版 | ❌ |
⚠️ 出图前必须完成的交互
使用人上传照片后,不得直接出图,必须先询问两项:
- 版式:
满幅出血(--fit bleed)还是留白贴票(--fit inset)? - 底部编号串:
--code填什么内容?允许中文,也可回答"不要"。
其余项(画幅、色系)可给默认。完整询问话术、选项默认值、多组素材提交格式见 references/interaction.md。
前置依赖
pip install pillow numpy python-barcode
python-barcode缺失时不报错,条形码自动退化为规则装饰条带(compose.decorative_bars)。要"真实可扫条码"须确认已安装。- 中文字体走系统回退:
msyh.ttc(Win)→PingFang.ttc(macOS)→NotoSansCJK(Linux)。编号串/日期/中文地名含中文时自动切中文字体。 - 标题字体随包内置(PlayfairDisplay / ZillaSlab / Inter),以 gzip+base64 文本(
assets/fonts/*.json)分发,
首次调用由compose.bundled_font()自动解包到系统临时目录并缓存,无需联网、无需额外依赖。
(以文本形式分发是为满足发布平台的文件类型白名单——平台不收.ttf。)
快速开始
# 单张(4:3 横版、票面 2.5:1 为默认)
python scripts/gen_ticket.py \
--photo "/path/照片1.jpg" \
--city 青岛 --date "9月21日" \
--code "手机摄影" --out ./out
# 同组多张(输出自动加序号 -01/-02/…)
python scripts/gen_ticket.py --photos 照片1.jpg 照片2.jpg 照片3.jpg \
--city 青岛 --date "9月21日" \
--code "手机摄影" --out ./out
# 多组批量(每组各自带地点/日期)
python scripts/gen_ticket.py --batch batch.json --out ./out
# 要样张比例(画幅 3:4、票面 1.7535:1)
python scripts/gen_ticket.py --photo p.jpg --city SHAO XING --date 2026-9 \
--size 34 --aspect 1.7535 --out ./out
产出:<地名>-<日期>[-序号].png + 同名 .palette.json(底色、票面、墨色、主色相、对比度、票面坐标、paper_spec、text_avail)。
参数速查
文案类
| 参数 | 默认 | 说明 |
|---|---|---|
--photo |
— | 单张模式:风景照路径,自动按 EXIF 转正 |
--photos |
— | 同组多张(同一地点/日期),输出自动加序号 |
--batch |
— | 批量 JSON(可含多组),见 references/interaction.md §3.3 |
--city |
— | 必填。地名,汉字或拼音都可以:青岛 / qingdao / QING DAO 都显示为 QINGDAO。转换规则见下 |
--city-cn |
— | 已停用(不再绘制中文小字),仅为兼容旧批处理文件保留该参数名 |
--date |
"" |
日期,如 2026-4 或 9月21日(中英均可) |
--num-top |
auto |
首行编号。auto = 按当前年份生成 No.<年>(2027 年即 No.2027);也可给固定文本;--num-top "" 隐藏 |
--num-mid |
"" |
中部编号,默认不画(旧版默认 NO.2026) |
--code |
"" |
底部编号串(询问项),留空则不画;支持中文,如 2VHIXINGTU、青岛行记 |
--barcode-value |
同 --code 的 ASCII 片段 |
条码内容(仅 ASCII)。--code 含中文时自动只取 ASCII;无可编码字符则退化为装饰条带 |
地名 → 大写拼音的转换规则
| 输入 | 输出 | 说明 |
|---|---|---|
青岛 |
QINGDAO |
汉字走 pypinyin 取音节,紧排为单个词 |
qingdao / QING DAO |
QINGDAO |
纯拉丁输入按非字母数字切分后大写,同样紧排 |
呼和浩特 |
HUHEHAOTE 或折行 HUHE/HAOTE |
音节边界折行,不会从词中间硬断 |
| (未装 pypinyin 时) | 原样大写 | 不报错,但不转换 |
> 折行行数由"字高最大者胜"决定:能放下就用单行,放不下才折成 2~3 行(按音节均衡)。
> ⚠️ 多音字以 pypinyin 默认读音为准(如 西安 → XIAN,与"先"同形;如需区分请直接传 XI AN)
版式类
| 参数 | 默认 | 说明 |
|---|---|---|
--size |
43 |
43(1440×1080,4:3 横版,默认) / 34(1080×1440) / 11(1440×1440) / 916(1080×1920),或自定义 1440x1080 |
--aspect |
2.5 |
票面本体宽高比("修长"程度)。2.5 为当前设计;传 1.7535 回到 9 张样张实测比例 |
--fit |
bleed |
询问项。bleed=满幅出血(照片铺满左侧、无留白);inset=留白贴票(圆角内嵌,四边等宽留纸边,净距=冲孔半径 0.0853H) |
--font-set |
display |
display=PlayfairDisplay 高对比衬线;slab=ZillaSlab 板衬线 |
--title-font |
— | 自定义标题 .ttf 路径 |
--focus |
0.5 |
照片裁剪纵向重心 0=顶部 1=底部。天空多的照片用 0.35~0.45 |
--seed |
7 |
纹理随机种子,同参数同种子结果可复现 |
配色类
| 参数 | 默认 | 说明 |
|---|---|---|
--theme |
tonal |
tonal=票面与底色同色系(默认);cream=票面恒米白 (242,236,224) |
--paper-lift |
0.035 |
tonal:票面明度相对底色的增量。渲染后实测 ΔL ≈ +0.04。底色很浅时上限约 0.04(再高撞 sRGB 上限、色相漂移) |
--paper-chroma-gain |
1.20 |
tonal:票面彩度 = 底色彩度 × 此倍数 |
--paper-hue-shift |
0.0 |
tonal:票面色相相对底色的偏移角度(0=严格同色系;手动指定票面色系用此参数) |
--bg-lightness |
0.955 |
背景底色基准 OKLCH 明度 0.60~0.98。渲染后的实际观感约 0.87~0.92(顶亮底暗) |
--bg-chroma-scale |
0.20 |
背景彩度 = 照片主色彩度 × 此系数。调大 → 底色更艳 |
--bg-hue-shift |
0.0 |
底色色相相对照片主色的偏移角度(整体色系旋转)。如 -60 把蓝天转暖中性 |
输出类
| 参数 | 默认 | 说明 |
|---|---|---|
--out |
./out |
输出目录,自动创建 |
> 输出格式恒为 PNG,不再提供 JPG 导出。
输出格式
默认票面宽高比 2.5:1(--aspect 2.5):
| 画布 | 像素 | 票面本体 | 占宽 | 适用 |
|---|---|---|---|---|
43 |
1440 × 1080 | 1013 × 405 | 70.3% | 默认;横版配图 / 公众号首图 / PPT |
43(--aspect 1.7535) |
1440 × 1080 | 848 × 484 | 58.9% | 样张比例、更"敦实" |
34 |
1080 × 1440 | 848 × 339 | 78.5% | 小红书 / 公众号竖版 |
11 |
1440 × 1440 | 1131 × 452 | 78.5% | 方形九宫格 |
916 |
1080 × 1920 | 848 × 339 | 78.5% | 竖屏 Story / 抖音 |
票面水平居中、垂直居中。样张比例的标称基准为样张 21210825_15(约 1131×645)。
竖版与 9:16 画布在修长票面下上下会留出较多背景 —— 刻意保留(供平台顶/底 UI 遮挡与后期加字)。
票面同色系配色(tonal)
- 取色:照片缩样 160×160 → 中位切分量化(Median-Cut),得 8 个主色及占比。
- 定主色:取权重最高、OKLCH 彩度 ≥ 0.05 且明度在
(0.15, 0.90)的"有彩色"。全图近黑白/低彩时退化为全图均色(80×80 缩样)的色相。 - 派生底色:锁定主色相,明度取
--bg-lightness,彩度 = 主色彩度 ×--bg-chroma-scale并夹在[0.008, 0.038]。 - 派生票面(tonal):与底色同色相,明度 = 底色基准 +
--paper-lift,彩度 = 底色彩度 ×--paper-chroma-gain。
→ 默认参数下渲染后实测 ΔL +0.041 / ΔH −0.0° / RGB 差 ≈ (13,15,15),即"同色系、浅一档、看得出分界"。 - 墨色:固定暖褐
(78,51,32)/ 近黑褐(40,34,25),用 WCAG 校验,对比度 < 4.5:1 时逐级降明度(实测 11.2~11.3)。
如何自动 / 手动选择票面色系
| 需求 | 做法 |
|---|---|
| 自动(默认):票面跟底色走 | --theme tonal,不改参数 |
| 票面比底色更浅/更接近(弱化区别) | --paper-lift 0.015 |
| 票面与底色区别更强 | 同时下调底色并加大增量:--bg-lightness 0.86 --paper-lift 0.055 |
| 想要"浅绿纸 / 灰绿底"(同第 9 张) | --bg-hue-shift -62 --bg-lightness 0.82 --paper-lift 0.070 --bg-chroma-scale 0.70 → 实测 ΔL +0.069~0.070(样张 21 为 +0.069);色相随照片而变,如 input-blue 得底 (184,202,179) / 纸 (204,226,197)。见 examples/out/example-43-bleed-qingdao.png |
| 票面换成相近但不同的色系 | --paper-hue-shift ±30~±60 |
| 回到恒定米白票面 | --theme cream |
> ⚠️ 已知限制:底色很浅(--bg-lightness ≈ 0.95)时,--paper-lift 超过约 0.04 会顶到 sRGB 上限——
> 票面趋于近白、且实测色相出现漂移(示例 huangshan 因该原因 ΔH = −18.5°)。
> 需要"浅色纸 / 灰底"的强对比时,应同时下调 --bg-lightness,而不是一味加大 lift。
背景与票面均叠加亚麻织纹;背景另有"左上最亮 → 右下最暗"的对角线性渐变(multiply,对角降幅约 0.05 明度)+ 极轻暗角;
票面叠纸纹与纸粒;照片做降饱和 0.86 + 微提对比 1.06 + 颗粒的统一调性。
> 背景明度已按同位置取样标定:左上/顶/左/底 四点与 8 张样张的中位偏差 MAE 0.008。
字体与匹配原则
匹配原则(四步):先定骨架类型(衬线 / 无衬线 / 板衬线)→ 再定笔画对比度与字重 → 再定字宽与大写高 → 最后按实测大写高反算字号(font_for_cap() 用 200px 探测 getbbox("H"))。
样张字体的笔画实测(列为 p75/p10 粗细比,取自样张 15 标题带):
| 行 | 角色 | 实测笔画(cap≈54px) | 推断骨架 | 实现选用 |
|---|---|---|---|---|
| 地名标题 | 大号全大写 | 粗笔画 p75 14~19px、细笔画 p10 2~5px,粗细比 ≈2.8 | 高对比「过渡体/Didone 系」粗衬线(有细笔画、非等线) | PlayfairDisplay-Variable,wght 900 |
| 首行/中部编号、日期 | 同族衬线 | 粗细比 2.2~3.5 | 同族衬线 | PlayfairDisplay-Variable,wght 560 |
| 编号串 | 无衬线中等字重 | 单层粗度、无衬线尖角 | 几何无衬线 | Inter-Variable,wght 620 |
| 中文小字 / 中文编号串 | 系统黑体 | — | 思源/雅黑类无衬线 | msyh.ttc(wght 400) |
- 中西文混排对齐:
compose.text_font()按内容自动选字体——含中文走中文字体、否则走西文字体;
两条路径都按目标字高反算字号(中文经CJK_EM_K = 0.80补偿),故同一行的中西文高度一致。 - 不要求逐字形一致:样本为商用模板字体,无法取得;采用同骨架 + 同对比度 + 同字重的开源替代,观感接近即可。
- 更硬朗的板衬线 →
--font-set slab(ZillaSlab);有授权字体 →--title-font /path.ttf。
常见调整
| 现象 | 处理 |
|---|---|
| 票面与底色区别太弱 | --paper-lift 0.05 并下调 --bg-lightness |
| 票面太接近纯白、颜色发灰 | 下调 --bg-lightness(给票面留出色域空间) |
| 底色太艳 / 太灰 | 调 --bg-chroma-scale(0.10~0.35) |
| 底色太深 / 太浅 | 调 --bg-lightness(0.94~0.96 渲染后最贴近样本) |
| 蓝色照片想要暖中性底 | --bg-hue-shift -60 |
| 照片主体被裁掉 | 调 --focus;人像/建筑在下半部用 0.65~0.8 |
| 觉得"留白贴票"的纸边不匀 | 已是四边严格相等(实测 35/35/35/35)。若看到不匀,请先确认照片本身不是"一边有天空"导致的视觉错觉 |
| 票面想更修长 / 更敦实 | --aspect 2.8(更修长)/ --aspect 1.7535(样张比例) |
| 长地名被压成小字 | 已内置:会在 1/2/3 行间择"字高最大者",无需手动干预 |
| 想恢复"地名 + 中文小字"两行 | 该行为已停用;如需中文小字请在 --date 或 --code 中体现 |
| 底部编号串含中文、条码退化成条带 | 正常(Code128 不支持中文);用 --barcode-value 单独给 ASCII 内容 |
| 不要条形码 / 编号 | 对应参数传空串 "" |
文件结构
travel-ticket-card/
├── SKILL.md
├── scripts/
│ ├── gen_ticket.py # 主入口 / CLI / 单张·同组多张·批量
│ ├── compose.py # 票面几何(含三角豁口)、排版、字号自适应、拼音折行、字体路由、条形码
│ ├── palette.py # 取色与 OKLCH 角色色派生(tonal / cream)
│ └── effects.py # 亚麻/纸纹/对角渐变/暗角/投影/照片调性
├── tools/ # 复测与标定脚本(开发/复核用,生成流程不依赖)
│ ├── verify_geometry.py # 梯度法量取票面与照片边界
│ ├── verify_edges.py # 背景建模 + 残差图,量取上/下边缘形状(含豁口)
│ ├── fit_background.py # 背景渐变场拟合
│ ├── peak_profile.py # 逐行像素偏差(定位弱边界)
│ └── structure_map.py # 结构图(无图像视觉能力时判读版式)
├── references/
│ ├── design-spec.md # 全部实测比例、色值、算法、验证与缺口
│ └── interaction.md # 提示词与交互规程(版式/编号串询问、批量提交格式)
├── assets/fonts/ # 内置字体数据(*.json = gzip+base64,首次使用解包到临时目录)
└── examples/
├── inputs/ # 示例输入照片
├── out/ # 示例产出(43 横版 × 2 版式 + 34/916 样张比例 + 11 cream + 同组多张)
└── batch.example.json
溯源说明
- 设计比例取自用户提供的 9 张车票式样张(1440×1920),标称基准为样张
21210825_15
(left=172 / top=658 / bot=1276,票面本体约 1131×645)。 - ⚠️ 9 张样张的几何并不一致:实测票面高/画布在 0.290–0.338、左边距 108–243px、照片宽 593–855px 浮动。
本 Skill 还原的是一套标称比例,而非某张"通用模板"。逐项实测表见references/design-spec.md§0。 - 撕线处的三角豁口(④):在样张 20 / 15 上实测到以撕线为中心、对称 V 形的上下边缘凹陷
(样张 20:上缘最深 +27px、下缘 −25px;样张 15:下缘深 26px、中心 u=0.716 与撕线位置重合)。
标称取值:半宽 3.0%W、深 4.0%H、中心 = 撕线 x。其余样张因纸色与底色色差仅 2~6 个色阶(如样张 13 为 2)
而无法判别,此项并非 9 张通用。量测方法与残差图见references/design-spec.md§2.1。 - ⚠️ 已知缺陷(未修):自动主色相选取不可靠 —— 中位切分按像素数切分,导致 8 个色箱权重恒等(实测均 0.1250),
"取权重最高"与min_weight过滤失效;且NEUTRAL_CHROMA = 0.050对真实照片偏严,会触发全图均色回退。
3 张示例输入中有 2 张的自动色相与稳健参考相差 >88°。因底色明度固定 0.955、彩度 ≤0.038,视觉上近白故不易察觉;
需精确控制底色色系时请显式传--bg-hue-shift。详见references/design-spec.md§11.3 第 9 条。 - 票面同色系:9 张样张实测纸面与底色 |ΔH| 中位 8.2°、纸面明度中位 +0.017、彩度 +0.004;
第 9 张(灰绿底)为 +0.069 的"浅绿纸 / 灰绿底"。见references/design-spec.md§5.4。 - 样张上固定出现的字符串
2VHIXINGTU经比对为原作者水印;现按用户要求改为「底部编号串」询问项,
由使用人填写(可中文),--code默认留空。 - v1.2.0 的设计变更(用户指定,非样张实测):画布默认 4:3 横版、票面宽高比 2.5:1、
信息区去掉「地名中文小字」与「中部编号」。这些是使用者对版式的主动选择,
不再等同于 9 张样张的比例;要复现样张观感请显式传--size 34 --aspect 1.7535。
references/design-spec.md中标注"样张实测"的数值仍以样张为准,未随之改动。 - v1.2.1 的几何修正(用户指定),三项,均已端到端量测(见
references/design-spec.md§1.3 / §2.2):
1. 留白贴票四边等宽:删除pad_h / pad_v / photo_w / photo_h,改为单一inset_pad = 0.0853H
(=冲孔半径),实测43画幅 35/35/35/35;抽出perf_px(W)消除 0.5px 取整误差
(修正前右净距为 34)。
2. 左侧补上大冲孔:中央半圆冲孔原先只做右缘 → 现左右两缘各一个,左右对称。
3. 齿形由"外凸"改为"内凹":原为外凸实心半圆,两张票面对接时会相互顶撞;
现为向内挖空的半圆、左右同 y 同半径,对接缝逐行一致(仅左有纸 / 仅右有纸各 0 行),
即两半拼合成一张完整的纸、只余规则圆孔。mask_pad(H)随之由ceil(bead_r·H)+1缩为常数 2。 - v1.2.2 的发布适配(仅打包形式变更,排版与输出规格未改):发布平台 SkillHub 只接受文本类文件
(.ttf等二进制被拒、单文件上限 1MB、总包上限 10MB),故做三处打包调整:
1. 三款内置字体改为assets/fonts/*.json(gzip+base64),运行时解包到%TEMP%/travel-ticket-card-fonts/;
解包字节与原.ttfSHA256 逐字节一致,同参数复现示例图时文字区域像素零差异(差异仅出现在
条形码区域,源于本机python-barcode版本不同,与字体无关)。
2.examples/out/*.png改为 256 色量化重新编码(每张 2.0~2.7MB → 0.39~0.47MB),分辨率与版式不变。
3.--title-font仍可指向使用者自备的任意.ttf,该文件由使用者提供、不随包分发。 - 示例输入照片来自 Unsplash(Unsplash License,可自由使用及商用),仅作演示。
详细坐标、色值与算法见 references/design-spec.md;交互规程见 references/interaction.md。