【Bug已解决】Add JoyAI-Image Edit Plus pipeline and model 解决方案
一、现象长什么样
JoyAI-Image Edit Plus 是社区在 Hugging Face Hub 上发布的一个指令式图像编辑扩散模型(输入一张图 + 一句编辑指令,输出编辑后的图)。很多用户第一时间照着官方用法去加载:
from diffusers import DiffusionPipeline pipe = DiffusionPipeline.from_pretrained( "joyai/JoyAI-Image-Edit-Plus", torch_dtype="auto", ) image = pipe( image="living_room.jpg", prompt="turn the sofa into a wooden one", ).images[0]但 diffusers 在写这篇文章时还没有这个模型的 pipeline 类,于是会立刻报错,常见三种:
ValueError: JoyAIImageEditPlusPipeline cannot be loaded since it was not found in diffusers pipelines.或者回退到仓库model_index.json里的_class_name后:
ImportError: cannot import name 'JoyAIImageEditPlusPipeline' from 'diffusers.pipelines'如果有人自己手写了类但没把组件 key 对齐,还会在权重加载阶段看到:
KeyError: 'unet.down_blocks.0.downsample.conv.weight' RuntimeError: Error(s) in loading state_dict for UNet2DConditionModel: Missing key(s) in state_dict: "conv_in.weight".核心事实是:权重已经在 Hub 上,但 diffusers 端没有对应的 pipeline 实现与组件映射,从from_pretrained到状态字典对齐整条链路都断了。
二、背景
JoyAI-Image Edit Plus 的架构与 InstructPix2Pix 同源:它把「源图」和「噪声」在通道维拼接后送入 UNet,文本编码器只用一份 CLIP 把编辑指令编码成条件。因此它的输入约定是:
image:待编辑的 PIL 图;prompt:编辑指令文本;- 输出:与输入同尺寸的编辑图。
diffusers 的DiffusionPipeline.from_pretrained并不是「盲加载」,它依赖两份信息:
- 仓库根目录的
model_index.json,里面列出每个组件(unet、text_encoder、vae、tokenizer、scheduler)对应的类名; - 全局的
_class_mapping注册表(diffusers/pipelines/__init__.py中通过register_to_safetensors注册),把类名解析成可导入的 Python 类。
只有当两者都对得上,from_pretrained才会按组件逐个构造对象并填入权重。新模型上线时如果只发了权重、没发 pipeline,链路在第一步就断了。
三、根因
把链路拆开看,缺失点有三处:
- 类名未注册:全局
_class_mapping里没有JoyAIImageEditPlusPipeline,from_pretrained拿到的_class_name无处解析,直接抛ValueError/ImportError。 - 组件映射缺失:即使临时写了一个类,如果
model_index.json里的组件名(如unet、vae、text_encoder)与类里config_name/ 文件布局不一致,from_pretrained找不到对应子目录或文件。 - 条件拼接约定未固化:InstructPix2Pix 类模型要求把源图编码后与噪声
torch.cat([cond, noise], dim=1),通道数翻倍。如果 pipeline 里忘了这步,UNet 的conv_in输入通道数对不上,权重一加载就Missing key。
一句话:根因不是权重坏了,而是「类注册 + 组件映射 + 条件拼接约定」这三件套在 diffusers 侧从未落地。
四、最小可运行复现
先复现「类不存在」这个最外层的错。下面这段不需要任何真实权重,只要 diffusers 装好就能跑:
import diffusers from diffusers import DiffusionPipeline # 伪造一个引用了不存在类的 model_index.json fake_index = { "_class_name": "JoyAIImageEditPlusPipeline", "_diffusers_version": diffusers.__version__, "unet": ("diffusers", "UNet2DConditionModel"), "text_encoder": ("transformers", "CLIPTextModel"), "tokenizer": ("transformers", "CLIPTokenizer"), "vae": ("diffusers", "AutoencoderKL"), "scheduler": ("diffusers", "DDIMScheduler"), "feature_extractor": ("transformers", "CLIPFeatureExtractor"), } import json, os, tempfile repo = tempfile.mkdtemp() with open(os.path.join(repo, "model_index.json"), "w") as f: json.dump(fake_index, f, indent=2) try: DiffusionPipeline.from_pretrained(repo) except Exception as e: print(type(e).__name__, e) # ValueError: JoyAIImageEditPlusPipeline cannot be loaded ...要复现「权重 key 对不上」,可以把一个真实 InstructPix2Pix 权重用错位的conv_in通道去加载,会稳定得到Missing key(s) in state_dict。这两种复现分别对应根因的第 1 点和第 3 点。
五、解决方案(第一层:最小直接修复)
最小修复就是先把类「造出来并注册」,让from_pretrained能跑通一次编辑。下面是一个最小可用、可运行的 pipeline 骨架(用真实 diffusers API,放到你自己的项目里即可用):
import torch from PIL import Image from transformers import CLIPTextModel, CLIPTokenizer, CLIPFeatureExtractor from diffusers import ( AutoencoderKL, ConfigMixin, DDIMScheduler, DiffusionPipeline, ModelMixin, UNet2DConditionModel, register_to_safetensors, ) from diffusers.pipelines.pipeline_utils import _is_model_card @register_to_safetensors class JoyAIImageEditPlusPipeline(DiffusionPipeline, ConfigMixin): def __init__(self, vae, text_encoder, tokenizer, unet, scheduler, feature_extractor): super().__init__() self.register_modules( vae=vae, text_encoder=text_encoder, tokenizer=tokenizer, unet=unet, scheduler=scheduler, feature_extractor=feature_extractor, ) @torch.no_grad() def __call__(self, image, prompt, num_inference_steps=50, guidance_scale=7.5, generator=None): device = self.unet.device # 1) 文本条件 tokens = self.tokenizer( prompt, return_tensors="pt", padding="max_length", max_length=self.tokenizer.model_max_length, truncation=True, ).to(device) text_embed = self.text_encoder(**tokens).last_hidden_state # 2) 图像编码成条件 img = image.resize((self.unet.config.sample_size, self.unet.config.sample_size)) px = self.feature_extractor(images=img, return_tensors="pt").pixel_values.to(device, self.vae.dtype) cond = self.vae.encode(px).latent_dist.mode() * 0.18215 # 3) 噪声 + 与条件在通道维拼接(InstructPix2Pix 约定) latents = torch.randn((1, 4, cond.shape[2], cond.shape[3]), generator=generator, device=device) latents = torch.cat([cond, latents], dim=1) self.scheduler.set_timesteps(num_inference_steps, device=device) for t in self.scheduler.timesteps: noise_pred = self.unet(latents, t, encoder_hidden_states=text_embed).sample latents = self.scheduler.step(noise_pred, t, latents).prev_sample out = (1 / 0.18215) * latents[:, 4:, :, :] # 只取噪声分支 out = self.vae.decode(out).sample return Image.fromarray(((out[0] * 0.5 + 0.5).clamp(0, 1) * 255).byte().permute(1, 2, 0).cpu().numpy())只要组件目录齐全,这个最小类就能让from_pretrained成功。注意torch.cat([cond, latents], dim=1)这一步是关键,漏掉它 UNet 的conv_in输入通道就对不上。
六、解决方案(第二层:结构性改进)
真正要把模型接进 diffusers 主干,需要把它落成标准目录结构,并准备一个「单一真源」dataclass 描述组件映射与约定,避免以后谁改一处另一处错位。下面是一个落库用的集成描述:
from dataclasses import dataclass, field from typing import Dict, List @dataclass(frozen=True) class JoyAiEditPlusIntegrator: """JoyAI-Image-Edit-Plus 接入 diffusers 的单一真源。""" repo_id: str = "joyai/JoyAI-Image-Edit-Plus" pipeline_class: str = "JoyAIImageEditPlusPipeline" package_path: str = "diffusers.pipelines.joyai_image_edit_plus" module_dir: str = "joyai_image_edit_plus" # model_index.json 中的组件名 -> 所在子目录/文件 components: Dict[str, str] = field(default_factory=lambda: { "unet": "unet", "text_encoder": "text_encoder", "tokenizer": "tokenizer", "vae": "vae", "scheduler": "scheduler", "feature_extractor": "feature_extractor", }) # 条件拼接约定 cond_channels: int = 4 noise_channels: int = 4 concat_dim: int = 1 # 输入尺寸约定 sample_size: int = 512 # 训练/推理期望的 dtype default_dtype: str = "fp16" def expected_module_files(self) -> List[str]: return [f"{self.module_dir}/{name}.py" for name in ( "__init__", "pipeline_" + self.module_dir, "model" )] def validate_component_keys(self, state_dict_keys: List[str]) -> List[str]: """检查 state_dict 里是否包含约定组件前缀,返回缺失项。""" missing = [] for comp in self.components: prefix = "" if comp in ("tokenizer", "scheduler", "feature_extractor") else comp + "." if comp in ("unet", "vae", "text_encoder") and not any( k.startswith(prefix) for k in state_dict_keys ): missing.append(comp) return missing配套地,在diffusers/pipelines/__init__.py增加:
from .joyai_image_edit_plus import JoyAIImageEditPlusPipeline # 在 _class_mapping 注册 register_to_safetensors(JoyAIImageEditPlusPipeline)并补diffusers/pipelines/joyai_image_edit_plus/pipeline_joyai_image_edit_plus.py、model.py、__init__.py、model_index.json模板,把组件名和JoyAiEditPlusIntegrator.components对齐。这样「注册、映射、拼接约定」三件事都有明确落点,后续维护者改配置时只动这一个 dataclass。
七、解决方案(第三层:断言 / CI 守护)
用 pytest 把「能加载、能跑一次、组件名对齐」固化成回归测试,防止以后误删注册或改错前缀:
import pytest from diffusers import DiffusionPipeline from mylib.joyai_integrator import JoyAiEditPlusIntegrator INTEGRATOR = JoyAiEditPlusIntegrator() def test_pipeline_registered(): # 类必须进全局注册表,否则 from_pretrained 会 ValueError from diffusers.pipelines import _class_mapping assert INTEGRATOR.pipeline_class in _class_mapping, "JoyAIImageEditPlusPipeline 未注册" def test_module_files_exist(repo_root): for f in INTEGRATOR.expected_module_files(): assert (repo_root / f).exists(), f"缺失接入文件: {f}" def test_component_keys_aligned(dummy_state_dict_keys): missing = INTEGRATOR.validate_component_keys(dummy_state_dict_keys) assert missing == [], f"组件前缀缺失: {missing}" def test_minimal_edit_runs(): pipe = DiffusionPipeline.from_pretrained(INTEGRATOR.repo_id, torch_dtype="auto") out = pipe(image="cat.jpg", prompt="make it snowy") assert out is not None and getattr(out, "images", None) is not None把上面 4 个用例接进 CI 的pipelines测试矩阵,并在 PR 模板里要求「新增 pipeline 必须同步更新JoyAiEditPlusIntegrator与_class_mapping」。这样以后再有同类模型接入,流程是可复制、可校验的。
八、排查清单
遇到「新模型加载不出来」按这个顺序查:
model_index.json的_class_name是否在diffusers.pipelines._class_mapping中?没有就ValueError/ImportError。- 每个组件名(unet/vae/text_encoder…)是否有对应子目录,且类名与
model_index.json一致? - UNet 的
conv_in输入通道是否等于cond_channels + noise_channels?InstructPix2Pix 类模型必须翻倍。 from_pretrained加载后是否真的把权重填进去了?用pipe.unet.conv_in.weight.sum().item()与 Hub 上unet权重对比,确认不是空张量。- 是否漏了
torch.cat([cond, latents], dim=1)?漏了会在scheduler.step之前就 shape 报错。 - dtype 是否统一:vae/tokenizer 多数用 fp16,混用 fp32 会在 concat 时报
expected all tensors to be same dtype。
九、小结
JoyAI-Image Edit Plus 的「Bug」本质是新模型上线时 diffusers 侧缺三件套:类未注册、model_index.json组件映射未对齐、条件拼接约定未固化。第一层用一个最小 pipeline +register_to_safetensors让from_pretrained跑通;第二层把目录结构和组件约定收敛到JoyAiEditPlusIntegrator这个 dataclass 单一真源;第三层用 pytest 守住「注册存在、文件齐全、key 对齐、能编辑一次」。照这个流程,以后任何同类指令式编辑模型接入 diffusers 都是同一套可复制动作。