选图的时候需要把多个方案摆在一起比较。这篇用
jobs.csv定义若干任务,每个任务包含提示词、尺寸和一组 seed。脚本以最多 2 个并发提交,然后把下载的图片拼成一张带标签的对比图,并生成manifest.csv(任务、seed、文件、sha256、提示词)。之后想复现某张图或做审计,都可以查这张表。
先看清成品(在 box 上对着模拟服务器运行)
- 3 个任务,共 8 张图,都成功了。
- 对比图为 768×870。
- 测试里混进了一个非法尺寸的任务和一个 5 个 seed 的任务,两者都被正确拒绝,没有拖垮整批。
合规提醒:Firefly Services API 面向企业,需要组织开通 Firefly Services,并在 Adobe Developer Console 创建 OAuth Server-to-Server 凭据。
CLIENT_SECRET和访问令牌只能放在服务器端,不要写进前端代码、仓库或截图。提示词和参考图要遵守 Adobe 的生成式 AI 使用准则,不要生成侵犯他人肖像、版权或商标的内容。在中国境内公开发布生成内容,要遵守《生成式人工智能服务管理暂行办法》,并按《人工智能生成合成内容标识办法》加上“AI 生成”标识。Firefly 按生成额度计费,额度取决于你的合同,本文不列价格。
运行说明:box 上没有 Firefly 凭据,所以没有真正生成过图片。文中的生成流程跑在我自己写的模拟服务器
fake_firefly.py上,接口形状照官方文档实现,图片是程序画的色块。用错误凭据对真实的 IMS 和 Firefly 接口做的请求是真实运行的,返回结果见 C 篇。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
batch.py |
读取 CSV,限并发生成,写出 manifest 和对比图 |
jobs.csv |
示例任务 |
ff_client.py、fake_firefly.py |
从 A 篇原样复制 |
test_batch.py |
端到端测试(包含失败的任务) |
需要准备哪些环境?
和 A 篇相同,另需 Noto CJK 字体(fonts-noto-cjk)来绘制中文标签。
文件结构
49-adobe-firefly-B-src/
├── batch.py
├── jobs.csv
├── ff_client.py
├── fake_firefly.py
└── test_batch.py
第 1 步:任务表
name,prompt,size,seeds
cat_flat,一只在写代码的橘猫,扁平插画,暖色,1024x1024,101;202;303
cat_photo,一只在写代码的橘猫,摄影,浅景深,1024x1024,101;202;303
lighthouse,黄昏海边灯塔,水彩,2048x2048,7;8
seed 之间用分号分隔,最多 4 个,因为 numVariations 的上限是 4。同一个提示词配同一个 seed,构图会比较接近,但 Adobe 并不保证每次生成的结果逐字节相同。所以要留存记录,就保存图片本身和它的 sha256,不要只记 seed。
第 2 步:批处理脚本
"""批量变体:读 jobs.csv(同一提示词多个 seed / 多种风格),限并发提交 Firefly,
下载结果、写 manifest.csv(含 seed 与 sha256,便于复现和审计),再拼一张带标签的对比图。"""
import csv, hashlib, sys, pathlib, json
from concurrent.futures import ThreadPoolExecutor
from PIL import Image, ImageDraw, ImageFont, ImageOps
from ff_client import Firefly, FireflyError
FONT = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
OUT = pathlib.Path("outputs")
def run_job(ff, row):
seeds = [int(s) for s in row["seeds"].split(";")]
if len(seeds) > 4:
return row["name"], None, "seed 最多 4 个(numVariations 上限 4)"
w, h = map(int, row["size"].split("x"))
try:
d = ff.wait(ff.submit(row["prompt"], n=len(seeds), size=(w, h), seeds=seeds))
except FireflyError as e:
return row["name"], None, f"HTTP {e.status}"
if d["status"] != "succeeded":
return row["name"], None, d.get("message", d["status"])
return row["name"], ff.download(d["result"], OUT / row["name"], row["name"]), "ok"
def contact_sheet(rows, results, path, cell=256):
font = ImageFont.truetype(FONT, 18)
ok = [(r, results[r["name"]]) for r in rows if results[r["name"]][0]]
cols = max(len(f) for _, (f, _) in ok)
sheet = Image.new("RGB", (cols * cell, len(ok) * (cell + 34)), "white")
d = ImageDraw.Draw(sheet)
for y, (r, (files, _)) in enumerate(ok):
top = y * (cell + 34)
d.text((6, top + 6), f"{r['name']}|{r['prompt'][:22]}", fill="black", font=font)
for x, (p, seed) in enumerate(files):
im = ImageOps.contain(Image.open(p).convert("RGB"), (cell, cell)) # thumbnail() 只缩不放
sheet.paste(im, (x * cell, top + 34))
d.text((x * cell + 6, top + 34 + cell - 26), f"seed {seed}", fill="white", font=font)
sheet.save(path); return path
def main(csv_path="jobs.csv", workers=2):
OUT.mkdir(exist_ok=True)
rows = list(csv.DictReader(open(csv_path, encoding="utf-8")))
ff = Firefly()
with ThreadPoolExecutor(workers) as ex: # 限并发,别一次把配额打满
results = {n: (f, msg) for n, f, msg in ex.map(lambda r: run_job(ff, r), rows)}
with open(OUT / "manifest.csv", "w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh); w.writerow(["job", "seed", "file", "sha256", "prompt"])
for r in rows:
for p, seed in results[r["name"]][0] or []:
w.writerow([r["name"], seed, p, hashlib.sha256(p.read_bytes()).hexdigest()[:16], r["prompt"]])
for n, (f, msg) in results.items():
print(("✓" if f else "✗"), n, msg, f"{len(f)} 张" if f else "")
if any(f for f, _ in results.values()):
print("对比图:", contact_sheet(rows, results, OUT / "contact_sheet.png"))
if __name__ == "__main__":
main(*sys.argv[1:])
第 3 步:运行
$ python fake_firefly.py & # 环境变量同 A 篇
$ python batch.py
✓ cat_flat ok 3 张
✓ cat_photo ok 3 张
✓ lighthouse ok 2 张
对比图: outputs/contact_sheet.png
$ head -4 outputs/manifest.csv
job,seed,file,sha256,prompt
cat_flat,101,outputs/cat_flat/cat_flat_0_seed101.png,c2acee76c40fcf89,一只在写代码的橘猫,扁平插画,暖色
cat_flat,202,outputs/cat_flat/cat_flat_1_seed202.png,880afd6a8efcbad7,一只在写代码的橘猫,扁平插画,暖色
cat_flat,303,outputs/cat_flat/cat_flat_2_seed303.png,af64a31b83f6f717,一只在写代码的橘猫,扁平插画,暖色
$ python -m pytest -q
1 passed in 2.65s
检查点:
- 对比图每行是一个任务,每格左下角标着 seed。
- 模拟服务器只按 seed 画图,所以 cat_flat 和 cat_photo 两行看起来一样。真实的 Firefly 会按提示词生成不同的画面。
调试示例
对比图上前两行是小图,中间留了大片空白。 模拟服务器返回的图片是 128×128(1024 的 1/8)。代码最初用的是 im.thumbnail((256, 256)),而 thumbnail() 只缩小、不放大,于是小图原样贴进 256 的格子,seed 标签也落到了空白处。只有 2048 的那一行是正常的。改用 ImageOps.contain(im, (256, 256)) 之后,图片会等比缩放到格子大小,放大缩小都可以,问题就解决了。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 整批跑得很慢 | 串行提交 | 用线程池,并发数按合同的限额设置 |
| 部分任务报 429 | 并发开得太高 | 降低 workers;客户端会按 Retry-After 等待 |
| 中文标签显示成方块 | 没有 CJK 字体 | 安装 Noto CJK,或修改 FONT 路径 |
| manifest 里缺了某个任务 | 该任务失败了(看 ✗ 那一行) | 根据报错修改 CSV,重跑这一行 |
模板规则
- 一个任务失败,不能让整批停下来,每个任务的结果单独记录。
- manifest 必须写下 seed 和哈希。
- 拼图时统一用
contain或fit缩放。
项目资源
| 用途 | 地址 |
|---|---|
| Firefly API 快速入门(取令牌、generate-async) | https://developer.adobe.com/firefly-services/docs/firefly-api/guides/ |
| 异步 API 使用说明(statusUrl 轮询) | https://developer.adobe.com/firefly-services/docs/firefly-api/guides/how-tos/using-async-apis |
| Seeds 说明 | https://developer.adobe.com/firefly-services/docs/firefly-api/guides/concepts/seeds/ |
| Adobe Developer Console | https://developer.adobe.com/console |
把对比图发给团队选定方案,再按 manifest 里记录的 seed 继续细化。