在 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/ |
图越多,越需要一本账。