AI 工具

用 CLIP 检查 AI 生成角色的一致性:相似度与特征复核

OpenArt 的“角色”功能可以让同一角色出现在不同画面里,但生成多了总会有几张走样。 这篇做 consistencycheck.py:用本地 CLIP 模型把每张候选图和参考图比较相似度,再逐项检查角色特征(红色短发、黄色雨衣……),给出“通过 / 需要人工看 / 不像同一角色”的初筛结果。

BINBHO · 图文教程

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

机器先筛一遍,人只看可疑的那几张。