AI 工具

用 Python 为 AI 生成图片建素材台账:哈希去重与授权记录

在 OpenArt 上试了很多模型、下载了一堆图,过几周就忘了哪张图是哪个模型、什么提示词、能不能商用。 这篇做 artledger.py:扫描下载文件夹,为每张图记录哈希、尺寸、提示词、模型、积分和授权备注,找出重复和近似重复(转成 JPEG 或放大过的副本),标出缺信息的图,并生成一个可在浏览器里查看的缩略图页面。

BINBHO · 图文教程

在 OpenArt 上试了很多模型、下载了一堆图,过几周就忘了哪张图是哪个模型、什么提示词、能不能商用。 这篇做 art_ledger.py:扫描下载文件夹,为每张图记录哈希、尺寸、提示词、模型、积分和授权备注,找出重复和近似重复(转成 JPEG 或放大过的副本),标出缺信息的图,并生成一个可在浏览器里查看的缩略图页面。

先看清成品

  • 7 张替身图建账:ref.png 的 JPEG 副本和 2 倍放大副本都被认出是近似重复(dHash 距离 0)。
  • 发现并修复:副本被误报“缺少模型和提示词”,改为继承原图信息。
  • 发现并修复:Pillow 13 提示 getdata 已弃用(计划在 Pillow 14 移除)。pytest 3 项通过。

合规提醒:OpenArt 帮助中心写明“目前没有公开 API”(No public API is available currently);服务条款 4.4 规定只能通过 openart.ai 或官方 App 使用生成服务,“只允许真人操作,不允许自动化访问、机器人或脚本”。所以这三篇都不自动操作 OpenArt:生成、下载和打分都在网页上手动完成,脚本只处理你下载到本地的文件。条款还写明免费套餐的输出可能带可见水印;不同模型的商用授权不同,以 OpenArt 页面和条款为准。不上传无授权的他人照片作为参考图。

运行说明:我没有 OpenArt 账号,也不能用脚本操作它。文中的图片是我在 box 上用本地 sd-turbo(Stability AI Community License)生成的替身,用来代替你从 OpenArt 下载的图;“模型A/B/C”的分数是我编的示例。所有脚本和测试都在 box 上真实运行过(2026-10-21,UTC+8)。

做完之后你会得到什么?

成果 说明
art_ledger.py 建台账、查重、生成缩略图页
notes.csv 你手动填写的信息(模型、提示词、授权)
ledger.csv / sheet.html 结果
test_ledger.py 3 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,pip install pillow pytest(本文为 Pillow 13.0.0)
OpenArt 网页上生成并手动下载图片;在 notes.csv 中记下当时看到的模型名和授权说明

关键事实

事实 来源
OpenArt 目前没有公开 API OpenArt 帮助中心
不允许自动化访问、机器人或脚本;免费套餐输出可能带可见水印 OpenArt 服务条款 4.4、4.5
dHash:缩成 9×8 灰度图,比较相邻像素明暗,得到 64 位指纹;缩放、转码后基本不变 通用图像指纹方法(本文实测)

文件结构

76-openart-A-src/
├── art_ledger.py
├── notes.csv
├── downloads/        # 替身图
└── test_ledger.py

第 1 步:脚本

"""OpenArt 素材台账:给下载到本地的图片建台账(哈希、尺寸、提示词、模型、授权备注),找出重复/近似重复,标出缺失信息。
OpenArt 目前没有公开 API:图片和信息都由你从网页手动下载/填写,本脚本只处理本地文件。"""
import argparse, csv, hashlib, html, json
from pathlib import Path
from PIL import Image

EXTS = {".png", ".jpg", ".jpeg", ".webp"}
FIELDS = ["file", "sha256", "width", "height", "dhash", "model", "prompt", "credits", "license_note", "source_url", "duplicate_of", "problems"]

def dhash(img, size=8):
    """差值哈希:缩放、转码后基本不变,用来找近似重复。"""
    g = img.convert("L").resize((size + 1, size), Image.LANCZOS)
    px = g.tobytes(); bits = 0  # L 模式每像素 1 字节;不用 getdata(Pillow 13 已弃用,计划在 14 移除)
    for r in range(size):
        for c in range(size):
            bits = (bits << 1) | (px[r * (size + 1) + c] > px[r * (size + 1) + c + 1])
    return f"{bits:016x}"

def hamming(a, b): return bin(int(a, 16) ^ int(b, 16)).count("1")

def read_notes(path):
    if not path or not Path(path).exists(): return {}
    with open(path, encoding="utf-8-sig") as f:
        return {r["file"]: r for r in csv.DictReader(f)}

def png_prompt(img):
    """如果图片自带文本元数据(比如本地生成工具写入的 parameters),读出提示词。"""
    raw = getattr(img, "text", {}).get("parameters") if hasattr(img, "text") else None
    if not raw: return ""
    try: return json.loads(raw).get("prompt", "")
    except ValueError: return raw[:200]

def build(folder, notes, near=6):
    rows = []
    for p in sorted(Path(folder).iterdir()):
        if p.suffix.lower() not in EXTS: continue
        img = Image.open(p)
        n = notes.get(p.name, {})
        row = {"file": p.name, "sha256": hashlib.sha256(p.read_bytes()).hexdigest()[:16],
               "width": img.width, "height": img.height, "dhash": dhash(img),
               "model": n.get("model", ""), "prompt": n.get("prompt") or png_prompt(img), "credits": n.get("credits", ""),
               "license_note": n.get("license_note", ""), "source_url": n.get("source_url", ""), "duplicate_of": "", "problems": ""}
        for prev in rows:  # 与之前的图比较
            d = 0 if prev["sha256"] == row["sha256"] else hamming(prev["dhash"], row["dhash"])
            if d <= near:
                row["duplicate_of"] = prev["file"] + ("(完全相同)" if prev["sha256"] == row["sha256"] else f"(近似,距离 {d})")
                for k in ("model", "prompt", "credits", "license_note", "source_url"):  # 副本继承原图信息
                    row[k] = row[k] or prev[k]
                break
        miss = [k for k in ("model", "prompt", "license_note") if not row[k]]
        if miss: row["problems"] = "缺少 " + "、".join(miss)
        rows.append(row)
    return rows

def contact_sheet(rows, folder, out):
    cells = "".join(
        f'<figure><img src="{html.escape(str(Path(folder) / r["file"]))}" width="200"><figcaption>{html.escape(r["file"])}<br>'
        f'{html.escape(r["model"] or "模型未填")}<br><b>{html.escape(r["duplicate_of"] or r["problems"])}</b></figcaption></figure>' for r in rows)
    Path(out).write_text(f'<!doctype html><meta charset="utf-8"><title>素材台账</title><style>body{{display:flex;flex-wrap:wrap;font:12px sans-serif}}figure{{width:210px}}</style>{cells}', encoding="utf-8")

def main(argv=None):
    ap = argparse.ArgumentParser(); ap.add_argument("folder"); ap.add_argument("--notes", default="notes.csv")
    ap.add_argument("--near", type=int, default=6, help="dHash 距离不超过此值算近似重复(0-64)")
    a = ap.parse_args(argv)
    rows = build(a.folder, read_notes(a.notes), a.near)
    with open("ledger.csv", "w", newline="", encoding="utf-8-sig") as f:
        w = csv.DictWriter(f, fieldnames=FIELDS); w.writeheader(); w.writerows(rows)
    contact_sheet(rows, a.folder, "sheet.html")
    for r in rows: print(f"{r['file']:20} {r['width']}x{r['height']}  {r['duplicate_of'] or '-':28} {r['problems'] or 'OK'}")
    print(f"共 {len(rows)} 张,重复 {sum(1 for r in rows if r['duplicate_of'])} 张,缺信息 {sum(1 for r in rows if r['problems'])} 张 → ledger.csv, sheet.html")

if __name__ == "__main__":
    main()

第 2 步:手动信息表

file,model,prompt,credits,license_note,source_url
ref.png,(在 OpenArt 页面上看到的模型名),portrait of a young woman with short red hair and a yellow raincoat,,商用前核对该模型条款,
same_park.png,(同上),,,商用前核对该模型条款,

第 3 步:测试

import csv
from PIL import Image, ImageDraw
import art_ledger as m

def pic(path, color, shape=0, size=256):
    im = Image.new("RGB", (size, size), "white"); d = ImageDraw.Draw(im)
    if shape == 0: d.ellipse((40, 40, 200, 200), fill=color)
    else: d.rectangle((10, 120, 250, 250), fill=color)
    im.save(path); return im

def test_dhash_survives_resize_and_jpeg(tmp_path):
    im = pic(tmp_path / "a.png", "red")
    im.resize((512, 512)).save(tmp_path / "b.png"); im.save(tmp_path / "c.jpg", quality=70)
    h = [m.dhash(Image.open(tmp_path / n)) for n in ("a.png", "b.png", "c.jpg")]
    assert m.hamming(h[0], h[1]) <= 6 and m.hamming(h[0], h[2]) <= 6

def test_different_images_far_apart(tmp_path):
    a = m.dhash(pic(tmp_path / "a.png", "red")); b = m.dhash(pic(tmp_path / "b.png", "blue", shape=1))
    assert m.hamming(a, b) > 6

def test_duplicate_inherits_notes_and_flags_missing(tmp_path):
    pic(tmp_path / "a.png", "red"); Image.open(tmp_path / "a.png").save(tmp_path / "a_copy.jpg")
    pic(tmp_path / "z.png", "blue", shape=1)
    rows = m.build(tmp_path, {"a.png": {"model": "M", "prompt": "p", "license_note": "个人使用"}})
    by = {r["file"]: r for r in rows}
    assert by["a_copy.jpg"]["duplicate_of"].startswith("a.png") and by["a_copy.jpg"]["model"] == "M" and not by["a_copy.jpg"]["problems"]
    assert by["z.png"]["problems"] == "缺少 model、prompt、license_note"

第 4 步:运行

$ python art_ledger.py downloads
other_park.png       512x512  -                            缺少 model、license_note
ref.png              512x512  -                            OK
ref_copy.jpg         512x512  ref.png(近似,距离 0)             OK
ref_upscaled.png     1024x1024  ref.png(近似,距离 0)             OK
same_kitchen.png     512x512  -                            缺少 model、license_note
same_park.png        512x512  -                            OK
village.png          512x512  -                            缺少 model、license_note
共 7 张,重复 2 张,缺信息 3 张 → ledger.csv, sheet.html
$ python -m pytest -q
3 passed

怎么检查结果?

  • “近似,距离 0”表示指纹完全相同;本例 5 张不同的图两两距离为 14–37,最接近的是构图相似的两张公园图(14)。阈值 --near 默认 6,误报多就调小。
  • 缺 license_note 的图,用于商用前先去 OpenArt 页面确认该模型的授权。
  • sheet.html 用相对路径引用图片,和 downloads 放在一起打开。

调试示例

1. 副本被误报缺信息。 第一版运行结果:

ref_copy.jpg         512x512  ref.png(近似,距离 0)             缺少 model、prompt、license_note
ref_upscaled.png     1024x1024  ref.png(近似,距离 0)             缺少 model、prompt、license_note
共 7 张,重复 2 张,缺信息 5 张

副本和原图是同一张图,没必要再填一遍。修复:认出是重复后,继承原图的模型、提示词、积分和授权备注。修复后缺信息的从 5 张降到 3 张。

2. Pillow 弃用警告。 运行时出现:

DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.

box 上是 Pillow 13.0.0。灰度图每个像素 1 字节,直接用 tobytes() 就能得到同样的数据,不依赖将被移除的接口。

常见问题

现象 原因 处理
不同的图被当成重复 构图非常相似 调小 --near,或看 sheet.html 人工确认
提示词为空 OpenArt 下载的图不带提示词 在 notes.csv 中手动填写
CSV 打开乱码 编码问题 脚本写的是 UTF-8 BOM,Excel 可直接打开

模板规则

  • 下载时就记下模型、提示词和授权。
  • 先查重再整理。
  • 不用脚本操作 OpenArt。
  • 商用前核对模型授权和水印。

项目资源

用途 地址
OpenArt 帮助中心(官方) https://openart.ai/help
OpenArt 服务条款(官方) https://openart.ai/suite/terms
OpenArt 官网 https://openart.ai
Pillow 文档 https://pillow.readthedocs.io/en/stable/

图越多,越需要一本账。