OpenArt 的“角色”功能可以让同一角色出现在不同画面里,但生成多了总会有几张走样。 这篇做
consistency_check.py:用本地 CLIP 模型把每张候选图和参考图比较相似度,再逐项检查角色特征(红色短发、黄色雨衣……),给出“通过 / 需要人工看 / 不像同一角色”的初筛结果。
先看清成品
- 4 张替身图真实运行:同一角色在公园、厨房的图与参考图相似度 0.653、0.645;另一个角色(老人)0.515。
- 发现真实问题:默认阈值 0.75 太高,所有图都不合格;改为用已知样本校准,得出 0.58。
- 厨房那张戴着兜帽、看不到头发,“红色短发”只有 0.39,被正确地标为需要人工看。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)。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
consistency_check.py |
相似度 + 特征检查 + 阈值校准 |
traits.json |
角色特征(中文名 → 正/反两句英文描述) |
make_standins.py |
生成替身图 |
test_check.py |
3 项测试(用假向量,不加载模型) |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | pip install torch transformers pillow pytest(本文为 torch 2.14.1 CPU、transformers 5.20.2) |
| CLIP 模型 | openai/clip-vit-base-patch32,首次运行自动从 Hugging Face 下载 |
| 替身图 | 可选:diffusers + sd-turbo(见 75 篇) |
关键事实
| 事实 | 来源 |
|---|---|
| CLIP 把图片和文字映射到同一个向量空间,可以比较图与图、图与文字的相似度 | CLIP 模型页面 |
| 模型卡写明:监控和人脸识别类用途一律不在适用范围内;在性别、种族、年龄分类上存在偏差 | CLIP 模型页面 |
| CLIP 衡量整体语义相似,背景和构图都会影响分数 | 本文实测 |
| OpenArt 不允许脚本访问:图片需手动下载后再检查 | OpenArt 服务条款 4.4 |
文件结构
76-openart-B-src/
├── consistency_check.py
├── traits.json
├── make_standins.py
├── standins/
├── standins_sheet.png
└── test_check.py
第 1 步:检查脚本
"""角色一致性初筛:把每张候选图和参考图做 CLIP 图像相似度,再逐项检查角色特征(零样本文本匹配)。
只是初筛,最终由人看图决定。模型:openai/clip-vit-base-patch32(本地运行)。"""
import argparse, json, sys
from pathlib import Path
import torch
from PIL import Image
from transformers import CLIPModel, CLIPProcessor
MODEL = "openai/clip-vit-base-patch32"
class Clip:
def __init__(self, name=MODEL):
self.m = CLIPModel.from_pretrained(name).eval(); self.p = CLIPProcessor.from_pretrained(name)
@torch.no_grad()
def images(self, paths):
x = self.p(images=[Image.open(p).convert("RGB") for p in paths], return_tensors="pt")
e = self.m.get_image_features(**x)
e = getattr(e, "pooler_output", e)
return torch.nn.functional.normalize(e, dim=-1)
@torch.no_grad()
def attribute(self, path, yes, no):
"""yes / no 两句描述,返回图片更像 yes 的概率。"""
x = self.p(text=[yes, no], images=Image.open(path).convert("RGB"), return_tensors="pt", padding=True)
return self.m(**x).logits_per_image.softmax(-1)[0, 0].item()
def check(clip, ref, cands, traits, sim_min=0.75, trait_min=0.6):
e = clip.images([ref] + cands)
rows = []
for i, c in enumerate(cands, 1):
sim = float(e[0] @ e[i])
t = {name: clip.attribute(c, yes, no) for name, (yes, no) in traits.items()}
fails = [n for n, v in t.items() if v < trait_min]
if sim >= sim_min and not fails: verdict = "通过"
elif sim < sim_min and len(fails) * 2 > len(t): verdict = "不像同一角色"
else: verdict = "需要人工看"
rows.append({"image": Path(c).name, "similarity": round(sim, 3), "traits": {k: round(v, 2) for k, v in t.items()},
"missing": fails, "verdict": verdict})
return rows
def calibrate(clip, ref, same, other):
"""用已知“同一角色”和“不同角色”的图,取两组相似度的中点作为阈值。"""
e = clip.images([ref] + same + other)
s_same = [float(e[0] @ e[i]) for i in range(1, 1 + len(same))]
s_other = [float(e[0] @ e[i]) for i in range(1 + len(same), len(e))]
if min(s_same) <= max(s_other):
raise SystemExit(f"两组相似度重叠(同一角色最低 {min(s_same):.3f},不同角色最高 {max(s_other):.3f}):CLIP 区分不了,换参考图或只靠人工")
return round((min(s_same) + max(s_other)) / 2, 3), s_same, s_other
def main(argv=None):
ap = argparse.ArgumentParser(); ap.add_argument("ref"); ap.add_argument("cands", nargs="*")
ap.add_argument("--traits", default="traits.json"); ap.add_argument("--sim-min", type=float, default=0.6)
ap.add_argument("--calibrate", nargs=2, metavar=("SAME,..", "OTHER,.."), help="已知同一角色/不同角色的图,逗号分隔")
a = ap.parse_args(argv)
if a.calibrate:
thr, ss, so = calibrate(Clip(), a.ref, a.calibrate[0].split(","), a.calibrate[1].split(","))
print(f"同一角色相似度 {[round(x, 3) for x in ss]},不同角色 {[round(x, 3) for x in so]} → 建议 --sim-min {thr}"); return
traits = json.loads(Path(a.traits).read_text(encoding="utf-8"))
rows = check(Clip(), a.ref, a.cands, traits, a.sim_min)
print("| 图片 | 与参考图相似度 | " + " | ".join(traits) + " | 结论 |")
print("| :--- | ---: | " + " | ".join("---:" for _ in traits) + " | :--- |")
for r in rows:
print(f"| {r['image']} | {r['similarity']} | " + " | ".join(str(r['traits'][k]) for k in traits) + f" | {r['verdict']}" + (f"(缺:{'、'.join(r['missing'])})" if r['missing'] else "") + " |")
Path("consistency.json").write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()
第 2 步:角色特征
{
"红色短发": ["a person with short red hair", "a person whose hair is not red or not visible"],
"黄色雨衣": ["a person wearing a yellow raincoat", "a person wearing a coat that is not yellow"]
}
第 3 步:生成替身图
"""用本地 sd-turbo 生成替身图(代替 OpenArt 的输出),复用 75A 的 txt2img.py。"""
import sys; sys.path.insert(0, "/tmp/p/75A")
from txt2img import load, generate, save
C = "a young woman with short red hair and a yellow raincoat"
JOBS = [("ref.png", f"portrait of {C}, plain grey studio background", 1),
("same_park.png", f"{C} walking in a green park, full body", 2),
("same_kitchen.png", f"{C} cooking in a small kitchen", 3),
("other_park.png", "an old man with a grey beard and a blue coat walking in a green park, full body", 2)]
pipe = load()
for name, prompt, seed in JOBS:
save(generate(pipe, prompt, seed), f"standins/{name}", {"prompt": prompt, "seed": seed, "model": "stabilityai/sd-turbo", "note": "OpenArt 输出的替身"})
print(name)
第 4 步:测试
import torch, pytest
import consistency_check as m
class FakeClip:
"""用固定向量代替 CLIP:测试判定逻辑,不加载模型。"""
def __init__(self, vecs, traits): self.vecs, self.tr = vecs, traits
def images(self, paths): return torch.nn.functional.normalize(torch.tensor([self.vecs[p] for p in paths], dtype=torch.float), dim=-1)
def attribute(self, path, yes, no): return self.tr[path][yes]
T = {"发色": ("red", "not red"), "雨衣": ("yellow", "not yellow")}
V = {"r": [1, 0], "a": [0.9, 0.44], "b": [0.3, 0.95]}
TR = {"a": {"red": 0.9, "yellow": 0.9}, "b": {"red": 0.1, "yellow": 0.2}}
def test_verdicts():
rows = m.check(FakeClip(V, TR), "r", ["a", "b"], T, sim_min=0.6)
assert rows[0]["verdict"] == "通过" and rows[1]["verdict"] == "不像同一角色"
def test_trait_missing_needs_human():
tr = dict(TR, a={"red": 0.3, "yellow": 0.9})
rows = m.check(FakeClip(V, tr), "r", ["a"], T, sim_min=0.6)
assert rows[0]["verdict"] == "需要人工看" and rows[0]["missing"] == ["发色"]
def test_calibrate_midpoint_and_overlap():
thr, _, _ = m.calibrate(FakeClip(V, TR), "r", ["a"], ["b"])
assert 0.3 < thr < 0.9
with pytest.raises(SystemExit):
m.calibrate(FakeClip(V, TR), "r", ["b"], ["a"])
第 5 步:运行
$ python make_standins.py
ref.png
same_park.png
same_kitchen.png
other_park.png
替身图(左起:参考图、同一角色在公园、同一角色在厨房、另一个角色在公园):

$ python consistency_check.py standins/ref.png --calibrate standins/same_park.png,standins/same_kitchen.png standins/other_park.png
同一角色相似度 [0.653, 0.645],不同角色 [0.515] → 建议 --sim-min 0.58
$ python consistency_check.py standins/ref.png standins/same_park.png standins/same_kitchen.png standins/other_park.png --sim-min 0.58
| 图片 | 与参考图相似度 | 红色短发 | 黄色雨衣 | 结论 |
| :--- | ---: | ---: | ---: | :--- |
| same_park.png | 0.653 | 0.43 | 0.97 | 需要人工看(缺:红色短发) |
| same_kitchen.png | 0.645 | 0.39 | 0.99 | 需要人工看(缺:红色短发) |
| other_park.png | 0.515 | 0.06 | 0.03 | 不像同一角色(缺:红色短发、黄色雨衣) |
$ python -m pytest -q
3 passed
怎么检查结果?
- 先用几张你确认过的“同一角色”和“不同角色”图校准阈值,不要用别人的数字。
- 公园那张头发看起来偏橙金色,“红色短发”只有 0.43:这类边界情况正是需要人看的。
- 全身远景中面部和头发很小,特征分数会偏低;可以先裁出上半身再检查。
- 我最初还写了一个“年轻女性”特征。CLIP 模型卡指出它在性别、年龄分类上有偏差,而且人脸识别类用途不在适用范围内,所以去掉了,只保留发型和服装。调试示例里的表格是去掉之前的第一版输出。
调试示例
阈值拍脑袋定,结果全军覆没。 第一版默认 --sim-min 0.75:
| same_park.png | 0.653 | 0.43 | 0.97 | 0.99 | 需要人工看(缺:红色短发) |
| same_kitchen.png | 0.645 | 0.39 | 0.99 | 0.99 | 需要人工看(缺:红色短发) |
| other_park.png | 0.515 | 0.06 | 0.03 | 0.0 | 需要人工看(缺:红色短发、黄色雨衣、年轻女性) |
同一角色和不同角色都是“需要人工看”,没有区分度。实际相似度:同一角色 0.645–0.653,不同角色 0.515,0.75 远高于两组。
修复:加 --calibrate,用已知样本计算两组的中点作为阈值(本例 0.58);两组重叠时直接报错,说明 CLIP 在这组图上区分不了。另外加了第三种结论“不像同一角色”(相似度低且多数特征不符),让明显不对的图不用再看。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 首次运行很慢 | 下载 CLIP 模型(约 600 MB) | 只需下载一次 |
| 分数都差不多 | 背景主导了相似度 | 参考图用干净背景;裁出角色再比 |
| 特征分数不稳定 | 英文描述太模糊 | 正反两句写得具体,例如“not red or not visible” |
模板规则
- CLIP 只做初筛,最终由人看图。
- 阈值用自己的样本校准。
- 角色特征只写发型、服装、配饰,不写性别、年龄、种族。
- 只检查虚构角色,不用于识别真实人物。
- 参考图不用无授权的他人照片。
项目资源
| 用途 | 地址 |
|---|---|
| OpenArt 帮助中心(官方) | https://openart.ai/help |
| OpenArt 服务条款(官方) | https://openart.ai/suite/terms |
| OpenArt 官网 | https://openart.ai |
| CLIP 模型页面 | https://huggingface.co/openai/clip-vit-base-patch32 |
| sd-turbo 模型页面(替身图) | https://huggingface.co/stabilityai/sd-turbo |
机器先筛一遍,人只看可疑的那几张。