设计实践

Adobe Firefly:用 Seed 表格批量生成并比较多个方案

选图的时候需要把多个方案摆在一起比较。这篇用 jobs.csv 定义若干任务,每个任务包含提示词、尺寸和一组 seed。脚本以最多 2 个并发提交,然后把下载的图片拼成一张带标签的对比图,并生成 manifest.csv(任务、seed、文件、sha256、提示词)。之后想复现某张图或做审计,都可以查这张表。

BINBHO · 图文教程

选图的时候需要把多个方案摆在一起比较。这篇用 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 继续细化。