ledits_pp model/pipeline review
Commit tested: 0f1abc4ae8b0eb2a3b40e82a310507281144c423
Review performed against the repository review rules.
Coverage checked: fast and slow test classes exist for both SD and SDXL LEdits++; fast tests passed locally with .venv (3 passed, 1 skipped for each file). Slow tests exist but were not run. Duplicate search was run with gh search issues/prs; no duplicates found for the issues below. Existing open issue #8826 covers a separate known LEdits++ empty attention-store crash, so I am not presenting that one as new.
Issue 1: LEditsPPInversionPipelineOutput is missing from lazy exports
Affected code:
| _import_structure["pipeline_leditspp_stable_diffusion"] = ["LEditsPPPipelineStableDiffusion"] |
| _import_structure["pipeline_leditspp_stable_diffusion_xl"] = ["LEditsPPPipelineStableDiffusionXL"] |
| |
| _import_structure["pipeline_output"] = ["LEditsPPDiffusionPipelineOutput", "LEditsPPDiffusionPipelineOutput"] |
Problem:
The lazy import structure exports LEditsPPDiffusionPipelineOutput twice and omits LEditsPPInversionPipelineOutput. The eager TYPE_CHECKING path imports both outputs, so behavior differs between slow/eager imports and normal lazy imports. diffusers.pipelines.__init__ also imports these outputs in the eager path but does not expose them in the lazy import structure.
Impact:
Public imports documented under pipelines.ledits_pp fail at runtime for the inversion output class.
Reproduction:
fromdiffusers.pipelines.ledits_ppimportLEditsPPInversionPipelineOutput# AttributeError: module diffusers.pipelines.ledits_pp has no attribute LEditsPPInversionPipelineOutput
Relevant precedent:
| @dataclass |
| classLEditsPPInversionPipelineOutput(BaseOutput): |
| """ |
| Output class for LEdits++ Diffusion pipelines. |
| |
| Args: |
| input_images (`list[PIL.Image.Image]` or `np.ndarray`) |
| list of the cropped and resized input images as PIL images of length `batch_size` or NumPy array of shape ` |
| (batch_size, height, width, num_channels)`. |
| vae_reconstruction_images (`list[PIL.Image.Image]` or `np.ndarray`) |
| list of VAE reconstruction of all input images as PIL images of length `batch_size` or NumPy array of shape |
| ` (batch_size, height, width, num_channels)`. |
| """ |
| |
| images: list[PIL.Image.Image] |np.ndarray |
| vae_reconstruction_images: list[PIL.Image.Image] |np.ndarray |
Suggested fix:
_import_structure["pipeline_output"] = [
"LEditsPPDiffusionPipelineOutput",
"LEditsPPInversionPipelineOutput",
]
Also either add both output classes to src/diffusers/pipelines/__init__.py lazy exports or remove the eager-only imports there.
Issue 2: SDXL editing after batched inversion has inconsistent embedding batch sizes
Affected code:
| ifenable_edit_guidanceandediting_prompt_embedsisNone: |
| editing_prompt_2=editing_prompt |
| |
| editing_prompts= [editing_prompt, editing_prompt_2] |
| edit_prompt_embeds_list= [] |
| |
| forediting_prompt, tokenizer, text_encoderinzip(editing_prompts, tokenizers, text_encoders): |
| ifisinstance(self, TextualInversionLoaderMixin): |
| editing_prompt=self.maybe_convert_prompt(editing_prompt, tokenizer) |
| |
| max_length=negative_prompt_embeds.shape[1] |
| edit_concepts_input=tokenizer( |
| # [x for item in editing_prompt for x in repeat(item, batch_size)], |
| editing_prompt, |
| padding="max_length", |
| max_length=max_length, |
| truncation=True, |
| return_tensors="pt", |
| return_length=True, |
| ) |
| num_edit_tokens=edit_concepts_input.length-2 |
| ifenable_edit_guidance: |
| prompt_embeds=torch.cat([prompt_embeds, edit_prompt_embeds], dim=0) |
| add_text_embeds=torch.cat([add_text_embeds, pooled_edit_embeds], dim=0) |
| edit_concepts_time_ids=add_time_ids.repeat(edit_prompt_embeds.shape[0], 1) |
| add_time_ids=torch.cat([add_time_ids, edit_concepts_time_ids], dim=0) |
| self.text_cross_attention_maps= [editing_prompt] ifisinstance(editing_prompt, str) elseediting_prompt |
| |
| prompt_embeds=prompt_embeds.to(device) |
| add_text_embeds=add_text_embeds.to(device) |
| add_time_ids=add_time_ids.to(device).repeat(batch_size*num_images_per_prompt, 1) |
Problem:
LEditsPPPipelineStableDiffusionXL.encode_prompt() encodes edit concepts once per concept, not once per (concept, image) pair. After batched inversion, latents are repeated by 1 + enabled_editing_prompts, but edit prompt embeddings and pooled embeddings are not expanded the same way.
Impact:
SDXL LEdits++ cannot edit a batch of inverted images with multiple edit prompts. The failure is currently untested because fast tests cover batched inversion only, not batched editing.
Reproduction:
fromdiffusersimportLEditsPPPipelineStableDiffusionXLfromtests.pipelines.ledits_pp.test_ledits_pp_stable_diffusion_xlimportLEditsPPPipelineStableDiffusionXLFastTestscase=LEditsPPPipelineStableDiffusionXLFastTests()
pipe=LEditsPPPipelineStableDiffusionXL(**case.get_dummy_components())
pipe.set_progress_bar_config(disable=True)
inputs=case.get_dummy_inversion_inputs("cpu")
inputs["num_inversion_steps"] =2inputs["skip"] =0.0pipe.invert(**inputs)
pipe(editing_prompt=["wearing glasses", "sunshine"], output_type="latent")
# RuntimeError: mat1 and mat2 shapes cannot be multiplied ...Relevant precedent:
| text_inputs=self.tokenizer( |
| [xforiteminediting_promptforxinrepeat(item, batch_size)], |
| padding="max_length", |
| max_length=max_length, |
| truncation=True, |
| return_tensors="pt", |
| return_length=True, |
| ) |
| |
| num_edit_tokens=text_inputs.length-2# not counting startoftext and endoftext |
| text_input_ids=text_inputs.input_ids |
| untruncated_ids=self.tokenizer( |
| [xforiteminediting_promptforxinrepeat(item, batch_size)], |
| padding="longest", |
Suggested fix:
Expand edit concept embeddings per image in __call__ or encode_prompt, while keeping num_edit_tokens indexed per concept. The implementation should preserve the inversion path, where editing_prompt is reused as the source prompt and already represents one prompt per image.
Issue 3: SDXL non-square image sizes are swapped for micro-conditioning
Affected code:
| width=x0.shape[2] *self.vae_scale_factor |
| height=x0.shape[3] *self.vae_scale_factor |
| self.size= (height, width) |
| add_text_embeds=negative_pooled_prompt_embeds |
| add_time_ids=self._get_add_time_ids( |
| self.size, |
| crops_coords_top_left, |
| self.size, |
| dtype=negative_pooled_prompt_embeds.dtype, |
| text_encoder_projection_dim=text_encoder_projection_dim, |
| ) |
Problem:
invert() computes width from latent height and height from latent width, then stores self.size = (height, width). For non-square images, SDXL added time IDs receive swapped original/target sizes. The target_size argument accepted by __call__ is also ignored; the call always uses self.size.
Impact:
Non-square SDXL edits are conditioned on the wrong dimensions, which can degrade output and makes the documented target_size parameter ineffective.
Reproduction:
fromdiffusersimportLEditsPPPipelineStableDiffusionXLfromtests.pipelines.ledits_pp.test_ledits_pp_stable_diffusion_xlimportLEditsPPPipelineStableDiffusionXLFastTestscase=LEditsPPPipelineStableDiffusionXLFastTests()
pipe=LEditsPPPipelineStableDiffusionXL(**case.get_dummy_components())
pipe.set_progress_bar_config(disable=True)
inputs=case.get_dummy_inversion_inputs("cpu")
inputs["image"] =inputs["image"][0].resize((64, 32))
inputs.update({"height": 32, "width": 64, "num_inversion_steps": 2, "skip": 0.0})
pipe.invert(**inputs)
print(pipe.size) # (64, 32), expected (32, 64)Relevant precedent:
| original_size=original_sizeor (height, width) |
| target_size=target_sizeor (height, width) |
Suggested fix:
height=x0.shape[-2] *self.vae_scale_factorwidth=x0.shape[-1] *self.vae_scale_factorself.size= (height, width)
Then use target_size = target_size or self.size in __call__.
Issue 4: Callback tensor allowlists include names that are not in scope
Affected code:
| model_cpu_offload_seq="text_encoder->unet->vae" |
| _exclude_from_cpu_offload= ["safety_checker"] |
| _callback_tensor_inputs= ["latents", "prompt_embeds", "negative_prompt_embeds"] |
| _optional_components= ["safety_checker", "feature_extractor", "image_encoder"] |
| ifcallback_on_step_endisnotNone: |
| callback_kwargs= {} |
| forkincallback_on_step_end_tensor_inputs: |
| callback_kwargs[k] =locals()[k] |
| callback_outputs=callback_on_step_end(self, i, t, callback_kwargs) |
| |
| latents=callback_outputs.pop("latents", latents) |
| # prompt_embeds = callback_outputs.pop("prompt_embeds", prompt_embeds) |
| negative_prompt_embeds=callback_outputs.pop("negative_prompt_embeds", negative_prompt_embeds) |
| _callback_tensor_inputs= [ |
| "latents", |
| "prompt_embeds", |
| "negative_prompt_embeds", |
| "add_text_embeds", |
| "add_time_ids", |
| "negative_pooled_prompt_embeds", |
| "negative_add_time_ids", |
| ] |
| # TODO: Check inputs |
| # 1. Check inputs. Raise error if not correct |
| # self.check_inputs( |
| # callback_steps, |
| # negative_prompt, |
| # negative_prompt_2, |
| # prompt_embeds, |
| # negative_prompt_embeds, |
| # pooled_prompt_embeds, |
| # negative_pooled_prompt_embeds, |
| # ) |
Problem:
The SD pipeline allows prompt_embeds, but the denoising loop has no prompt_embeds local. The SDXL pipeline allows negative_add_time_ids, but that local is commented out and never defined; SDXL also comments out its check_inputs() call, so invalid callback tensor names are not rejected early.
Impact:
Documented callback customization crashes with KeyError instead of either passing the requested tensor or raising the normal validation error.
Reproduction:
fromdiffusersimportLEditsPPPipelineStableDiffusionfromtests.pipelines.ledits_pp.test_ledits_pp_stable_diffusionimportLEditsPPPipelineStableDiffusionFastTestscase=LEditsPPPipelineStableDiffusionFastTests()
pipe=LEditsPPPipelineStableDiffusion(**case.get_dummy_components())
pipe.set_progress_bar_config(disable=True)
inputs=case.get_dummy_inversion_inputs("cpu")
inputs.update({"image": inputs["image"][0], "num_inversion_steps": 1, "skip": 0.0})
pipe.invert(**inputs)
defcb(pipe, step, timestep, kwargs):
returnkwargspipe(
editing_prompt="cat",
output_type="latent",
use_intersect_mask=False,
callback_on_step_end=cb,
callback_on_step_end_tensor_inputs=["prompt_embeds"],
)
# KeyError: 'prompt_embeds'Relevant precedent:
| _callback_tensor_inputs= [ |
| "latents", |
| "prompt_embeds", |
| "add_text_embeds", |
| "add_time_ids", |
| ] |
| ifcallback_on_step_end_tensor_inputsisnotNoneandnotall( |
| kinself._callback_tensor_inputsforkincallback_on_step_end_tensor_inputs |
| ): |
| raiseValueError( |
| f"`callback_on_step_end_tensor_inputs` has to be in {self._callback_tensor_inputs}, but found {[kforkincallback_on_step_end_tensor_inputsifknotinself._callback_tensor_inputs]}" |
Suggested fix:
Make _callback_tensor_inputs match actual denoising-loop locals, call check_inputs() in SDXL, and add focused fast tests for every allowed callback tensor.
Issue 5: SDXL IP-Adapter path calls the VAE image encoder instead of IP-Adapter encoding
Affected code:
| ifip_adapter_imageisnotNone: |
| # TODO: fix image encoding |
| image_embeds, negative_image_embeds=self.encode_image(ip_adapter_image, device, num_images_per_prompt) |
| ifself.do_classifier_free_guidance: |
| image_embeds=torch.cat([negative_image_embeds, image_embeds]) |
| image_embeds=image_embeds.to(device) |
| # Modified from diffusers.pipelines.ledits_pp.pipeline_leditspp_stable_diffusion.LEditsPPPipelineStableDiffusion.encode_image |
| defencode_image(self, image, dtype=None, height=None, width=None, resize_mode="default", crops_coords=None): |
| image=self.image_processor.preprocess( |
| image=image, height=height, width=width, resize_mode=resize_mode, crops_coords=crops_coords |
| ) |
| height, width=image.shape[-2:] |
| ifheight%32!=0orwidth%32!=0: |
| raiseValueError( |
| "Image height and width must be a factor of 32. " |
| "Consider down-sampling the input using the `height` and `width` parameters" |
| ) |
| resized=self.image_processor.postprocess(image=image, output_type="pil") |
| |
| ifmax(image.shape[-2:]) >self.vae.config["sample_size"] *1.5: |
| logger.warning( |
| "Your input images far exceed the default resolution of the underlying diffusion model. " |
| "The output images may contain severe artifacts! " |
| "Consider down-sampling the input using the `height` and `width` parameters" |
| ) |
| image=image.to(self.device, dtype=dtype) |
| needs_upcasting=self.vae.dtype==torch.float16andself.vae.config.force_upcast |
| |
| ifneeds_upcasting: |
| image=image.float() |
| self.upcast_vae() |
| |
| x0=self.vae.encode(image).latent_dist.mode() |
| x0=x0.to(dtype) |
| # cast back to fp16 if needed |
| ifneeds_upcasting: |
| self.vae.to(dtype=torch.float16) |
| |
| x0=self.vae.config.scaling_factor*x0 |
| returnx0, resized |
Problem:
__call__() accepts ip_adapter_image, but calls self.encode_image(ip_adapter_image, device, num_images_per_prompt). In this pipeline, encode_image() is the VAE inversion helper, not the CLIP/IP-Adapter image encoder from SDXL. The positional arguments are interpreted as dtype and height.
Impact:
The advertised IP-Adapter path is unusable and fails before preparing image embeddings.
Reproduction:
fromdiffusersimportLEditsPPPipelineStableDiffusionXLfromtests.pipelines.ledits_pp.test_ledits_pp_stable_diffusion_xlimportLEditsPPPipelineStableDiffusionXLFastTestscase=LEditsPPPipelineStableDiffusionXLFastTests()
pipe=LEditsPPPipelineStableDiffusionXL(**case.get_dummy_components())
pipe.set_progress_bar_config(disable=True)
inputs=case.get_dummy_inversion_inputs("cpu")
inputs.update({"image": inputs["image"][0], "num_inversion_steps": 1, "skip": 0.0})
pipe.invert(**inputs)
pipe(editing_prompt="cat", ip_adapter_image=inputs["image"], output_type="latent")
# ValueError: height and width must be > 0Relevant precedent:
| defencode_image(self, image, device, num_images_per_prompt, output_hidden_states=None): |
| dtype=next(self.image_encoder.parameters()).dtype |
| |
| ifnotisinstance(image, torch.Tensor): |
| image=self.feature_extractor(image, return_tensors="pt").pixel_values |
| |
| image=image.to(device=device, dtype=dtype) |
| ifoutput_hidden_states: |
| image_enc_hidden_states=self.image_encoder(image, output_hidden_states=True).hidden_states[-2] |
| image_enc_hidden_states=image_enc_hidden_states.repeat_interleave(num_images_per_prompt, dim=0) |
| uncond_image_enc_hidden_states=self.image_encoder( |
| torch.zeros_like(image), output_hidden_states=True |
| ).hidden_states[-2] |
| uncond_image_enc_hidden_states=uncond_image_enc_hidden_states.repeat_interleave( |
| num_images_per_prompt, dim=0 |
| ) |
| returnimage_enc_hidden_states, uncond_image_enc_hidden_states |
| else: |
| image_embeds=self.image_encoder(image).image_embeds |
| image_embeds=image_embeds.repeat_interleave(num_images_per_prompt, dim=0) |
| uncond_image_embeds=torch.zeros_like(image_embeds) |
| |
| returnimage_embeds, uncond_image_embeds |
| |
| # Copied from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion.StableDiffusionPipeline.prepare_ip_adapter_image_embeds |
| defprepare_ip_adapter_image_embeds( |
| self, ip_adapter_image, ip_adapter_image_embeds, device, num_images_per_prompt, do_classifier_free_guidance |
| ): |
| image_embeds= [] |
| ifdo_classifier_free_guidance: |
| negative_image_embeds= [] |
| ifip_adapter_image_embedsisNone: |
| ifnotisinstance(ip_adapter_image, list): |
| ip_adapter_image= [ip_adapter_image] |
| |
| iflen(ip_adapter_image) !=len(self.unet.encoder_hid_proj.image_projection_layers): |
| raiseValueError( |
| f"`ip_adapter_image` must have same length as the number of IP Adapters. Got {len(ip_adapter_image)} images and {len(self.unet.encoder_hid_proj.image_projection_layers)} IP Adapters." |
| ) |
| |
| forsingle_ip_adapter_image, image_proj_layerinzip( |
| ip_adapter_image, self.unet.encoder_hid_proj.image_projection_layers |
| ): |
| output_hidden_state=notisinstance(image_proj_layer, ImageProjection) |
| single_image_embeds, single_negative_image_embeds=self.encode_image( |
| single_ip_adapter_image, device, 1, output_hidden_state |
| ) |
| |
| image_embeds.append(single_image_embeds[None, :]) |
| ifdo_classifier_free_guidance: |
| negative_image_embeds.append(single_negative_image_embeds[None, :]) |
| else: |
| forsingle_image_embedsinip_adapter_image_embeds: |
| ifdo_classifier_free_guidance: |
| single_negative_image_embeds, single_image_embeds=single_image_embeds.chunk(2) |
| negative_image_embeds.append(single_negative_image_embeds) |
| image_embeds.append(single_image_embeds) |
| |
| ip_adapter_image_embeds= [] |
| fori, single_image_embedsinenumerate(image_embeds): |
| single_image_embeds=torch.cat([single_image_embeds] *num_images_per_prompt, dim=0) |
| ifdo_classifier_free_guidance: |
| single_negative_image_embeds=torch.cat([negative_image_embeds[i]] *num_images_per_prompt, dim=0) |
| single_image_embeds=torch.cat([single_negative_image_embeds, single_image_embeds], dim=0) |
| |
| single_image_embeds=single_image_embeds.to(device=device) |
| ip_adapter_image_embeds.append(single_image_embeds) |
| |
| returnip_adapter_image_embeds |
| ifip_adapter_imageisnotNoneorip_adapter_image_embedsisnotNone: |
| image_embeds=self.prepare_ip_adapter_image_embeds( |
| ip_adapter_image, |
| ip_adapter_image_embeds, |
| device, |
| batch_size*num_images_per_prompt, |
| self.do_classifier_free_guidance, |
| ) |
Suggested fix:
Rename the VAE helper to something like encode_vae_image(), restore/copy SDXL’s encode_image() and prepare_ip_adapter_image_embeds(), add ip_adapter_image_embeds, and include image_encoder in the offload sequence.
Issue 6: invert() and cross-attention masking permanently replace user attention processors
Affected code:
| defprepare_unet(self, attention_store, PnP: bool=False): |
| attn_procs= {} |
| fornameinself.unet.attn_processors.keys(): |
| ifname.startswith("mid_block"): |
| place_in_unet="mid" |
| elifname.startswith("up_blocks"): |
| place_in_unet="up" |
| elifname.startswith("down_blocks"): |
| place_in_unet="down" |
| else: |
| continue |
| |
| if"attn2"innameandplace_in_unet!="mid": |
| attn_procs[name] =LEDITSCrossAttnProcessor( |
| attention_store=attention_store, |
| place_in_unet=place_in_unet, |
| pnp=PnP, |
| editing_prompts=self.enabled_editing_prompts, |
| ) |
| else: |
| attn_procs[name] =AttnProcessor() |
| |
| self.unet.set_attn_processor(attn_procs) |
| # Reset attn processor, we do not want to store attn maps during inversion |
| self.unet.set_attn_processor(AttnProcessor()) |
| # Copied from diffusers.pipelines.ledits_pp.pipeline_leditspp_stable_diffusion.LEditsPPPipelineStableDiffusion.prepare_unet |
| defprepare_unet(self, attention_store, PnP: bool=False): |
| attn_procs= {} |
| fornameinself.unet.attn_processors.keys(): |
| ifname.startswith("mid_block"): |
| place_in_unet="mid" |
| elifname.startswith("up_blocks"): |
| place_in_unet="up" |
| elifname.startswith("down_blocks"): |
| place_in_unet="down" |
| else: |
| continue |
| |
| if"attn2"innameandplace_in_unet!="mid": |
| attn_procs[name] =LEDITSCrossAttnProcessor( |
| attention_store=attention_store, |
| place_in_unet=place_in_unet, |
| pnp=PnP, |
| editing_prompts=self.enabled_editing_prompts, |
| ) |
| else: |
| attn_procs[name] =AttnProcessor() |
| |
| self.unet.set_attn_processor(attn_procs) |
| # Reset attn processor, we do not want to store attn maps during inversion |
| self.unet.set_attn_processor(AttnProcessor()) |
Problem:
invert() unconditionally calls self.unet.set_attn_processor(AttnProcessor()). prepare_unet() also replaces all processors with either LEDITSCrossAttnProcessor or plain AttnProcessor(). Neither path restores the original processors.
Impact:
Any configured attention backend or custom processor, including AttnProcessor2_0, xFormers-style processors, LoRA/IP-Adapter processors, or user-supplied processors, is silently discarded after inversion or masked editing.
Reproduction:
fromdiffusersimportLEditsPPPipelineStableDiffusionfromdiffusers.models.attention_processorimportAttnProcessor2_0fromtests.pipelines.ledits_pp.test_ledits_pp_stable_diffusionimportLEditsPPPipelineStableDiffusionFastTestscase=LEditsPPPipelineStableDiffusionFastTests()
pipe=LEditsPPPipelineStableDiffusion(**case.get_dummy_components())
pipe.unet.set_attn_processor(AttnProcessor2_0())
before= {type(p).__name__forpinpipe.unet.attn_processors.values()}
inputs=case.get_dummy_inversion_inputs("cpu")
inputs.update({"image": inputs["image"][0], "num_inversion_steps": 1, "skip": 0.0})
pipe.set_progress_bar_config(disable=True)
pipe.invert(**inputs)
after= {type(p).__name__forpinpipe.unet.attn_processors.values()}
print(before, after) # {'AttnProcessor2_0'} {'AttnProcessor'}Relevant precedent:
| classAttnProcessor2_0: |
| r""" |
| Processor for implementing scaled dot-product attention (enabled by default if you're using PyTorch 2.0). |
| """ |
| |
| def__init__(self): |
| ifnothasattr(F, "scaled_dot_product_attention"): |
| raiseImportError("AttnProcessor2_0 requires PyTorch 2.0, to use it, please upgrade PyTorch to 2.0.") |
| |
| def__call__( |
| self, |
| attn: Attention, |
| hidden_states: torch.Tensor, |
| encoder_hidden_states: torch.Tensor|None=None, |
| attention_mask: torch.Tensor|None=None, |
| temb: torch.Tensor|None=None, |
| *args, |
| **kwargs, |
| ) ->torch.Tensor: |
| iflen(args) >0orkwargs.get("scale", None) isnotNone: |
| deprecation_message="The `scale` argument is deprecated and will be ignored. Please remove it, as passing it will raise an error in the future. `scale` should directly be passed while calling the underlying pipeline component i.e., via `cross_attention_kwargs`." |
| deprecate("scale", "1.0.0", deprecation_message) |
| |
| residual=hidden_states |
| ifattn.spatial_normisnotNone: |
| hidden_states=attn.spatial_norm(hidden_states, temb) |
| |
| input_ndim=hidden_states.ndim |
| |
| ifinput_ndim==4: |
| batch_size, channel, height, width=hidden_states.shape |
| hidden_states=hidden_states.view(batch_size, channel, height*width).transpose(1, 2) |
| |
| batch_size, sequence_length, _= ( |
| hidden_states.shapeifencoder_hidden_statesisNoneelseencoder_hidden_states.shape |
| ) |
| |
| ifattention_maskisnotNone: |
| attention_mask=attn.prepare_attention_mask(attention_mask, sequence_length, batch_size) |
| # scaled_dot_product_attention expects attention_mask shape to be |
| # (batch, heads, source_length, target_length) |
| attention_mask=attention_mask.view(batch_size, attn.heads, -1, attention_mask.shape[-1]) |
| |
| ifattn.group_normisnotNone: |
| hidden_states=attn.group_norm(hidden_states.transpose(1, 2)).transpose(1, 2) |
| |
| query=attn.to_q(hidden_states) |
| |
| ifencoder_hidden_statesisNone: |
| encoder_hidden_states=hidden_states |
| elifattn.norm_cross: |
| encoder_hidden_states=attn.norm_encoder_hidden_states(encoder_hidden_states) |
| |
| key=attn.to_k(encoder_hidden_states) |
| value=attn.to_v(encoder_hidden_states) |
| |
| inner_dim=key.shape[-1] |
| head_dim=inner_dim//attn.heads |
| |
| query=query.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) |
| |
| key=key.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) |
| value=value.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) |
| |
| ifattn.norm_qisnotNone: |
| query=attn.norm_q(query) |
| ifattn.norm_kisnotNone: |
| key=attn.norm_k(key) |
| |
| # the output of sdp = (batch, num_heads, seq_len, head_dim) |
| # TODO: add support for attn.scale when we move to Torch 2.1 |
| hidden_states=F.scaled_dot_product_attention( |
| query, key, value, attn_mask=attention_mask, dropout_p=0.0, is_causal=False |
| ) |
| |
| hidden_states=hidden_states.transpose(1, 2).reshape(batch_size, -1, attn.heads*head_dim) |
| hidden_states=hidden_states.to(query.dtype) |
| |
| # linear proj |
| hidden_states=attn.to_out[0](hidden_states) |
| # dropout |
| hidden_states=attn.to_out[1](hidden_states) |
| |
| ifinput_ndim==4: |
| hidden_states=hidden_states.transpose(-1, -2).reshape(batch_size, channel, height, width) |
| |
| ifattn.residual_connection: |
| hidden_states=hidden_states+residual |
| |
| hidden_states=hidden_states/attn.rescale_output_factor |
| |
Suggested fix:
Store the original unet.attn_processors before installing LEdits processors and restore them in a finally block after the temporary operation. For non-intercepted attention layers, preserve the existing processor instead of replacing it with bare AttnProcessor().
Issue 7: CPU offload paths use self.device instead of _execution_device
Affected code:
| edit_concepts, uncond_embeddings, num_edit_tokens=self.encode_prompt( |
| editing_prompt=editing_prompt, |
| device=self.device, |
| num_images_per_prompt=num_images_per_prompt, |
| enable_edit_guidance=enable_edit_guidance, |
| negative_prompt=negative_prompt, |
| editing_prompt_embeds=editing_prompt_embeds, |
| negative_prompt_embeds=negative_prompt_embeds, |
| lora_scale=lora_scale, |
| clip_skip=self.clip_skip, |
| ) |
| |
| # For classifier free guidance, we need to do two forward passes. |
| # Here we concatenate the unconditional and text embeddings into a single batch |
| # to avoid doing two forward passes |
| ifenable_edit_guidance: |
| text_embeddings=torch.cat([uncond_embeddings, edit_concepts]) |
| self.text_cross_attention_maps= [editing_prompt] ifisinstance(editing_prompt, str) elseediting_prompt |
| else: |
| text_embeddings=torch.cat([uncond_embeddings]) |
| |
| # 4. Prepare timesteps |
| # self.scheduler.set_timesteps(num_inference_steps, device=self.device) |
| timesteps=self.inversion_steps |
| t_to_idx= {int(v): kfork, vinenumerate(timesteps[-zs.shape[0] :])} |
| |
| ifuse_cross_attn_mask: |
| self.attention_store=LeditsAttentionStore( |
| average=store_averaged_over_steps, |
| batch_size=batch_size, |
| max_size=(latents.shape[-2] /4.0) * (latents.shape[-1] /4.0), |
| max_resolution=None, |
| ) |
| self.prepare_unet(self.attention_store, PnP=False) |
| resolution=latents.shape[-2:] |
| att_res= (int(resolution[0] /4), int(resolution[1] /4)) |
| |
| # 5. Prepare latent variables |
| num_channels_latents=self.unet.config.in_channels |
| latents=self.prepare_latents( |
| batch_size*num_images_per_prompt, |
| num_channels_latents, |
| None, |
| None, |
| text_embeddings.dtype, |
| self.device, |
| latents, |
| ) |
| xts=torch.zeros(size=variance_noise_shape, device=self.device, dtype=uncond_embedding.dtype) |
| |
| fortinreversed(timesteps): |
| idx=num_inversion_steps-t_to_idx[int(t)] -1 |
| noise=randn_tensor(shape=x0.shape, generator=generator, device=self.device, dtype=x0.dtype) |
| xts[idx] =self.scheduler.add_noise(x0, noise, torch.Tensor([t])) |
| xts=torch.cat([x0.unsqueeze(0), xts], dim=0) |
| |
| self.scheduler.set_timesteps(len(self.scheduler.timesteps)) |
| # noise maps |
| zs=torch.zeros(size=variance_noise_shape, device=self.device, dtype=uncond_embedding.dtype) |
| ifuse_cross_attn_mask: |
| self.smoothing=LeditsGaussianSmoothing(self.device) |
| |
| ifuser_maskisnotNone: |
| user_mask=user_mask.to(self.device) |
| image=image.to(self.device, dtype=dtype) |
| needs_upcasting=self.vae.dtype==torch.float16andself.vae.config.force_upcast |
| |
| ifneeds_upcasting: |
| image=image.float() |
| self.upcast_vae() |
| |
| x0=self.vae.encode(image).latent_dist.mode() |
| x0=x0.to(dtype) |
| xts=torch.zeros(size=variance_noise_shape, device=self.device, dtype=negative_prompt_embeds.dtype) |
| |
| fortinreversed(timesteps): |
| idx=num_inversion_steps-t_to_idx[int(t)] -1 |
| noise=randn_tensor(shape=x0.shape, generator=generator, device=self.device, dtype=x0.dtype) |
| xts[idx] =self.scheduler.add_noise(x0, noise, t.unsqueeze(0)) |
Problem:
Both pipelines declare model_cpu_offload_seq, but many runtime tensors are moved to self.device. Under enable_model_cpu_offload(), self.device can remain CPU while _execution_device is the actual accelerator. Standard pipelines use _execution_device at call time for this reason.
Impact:
CPU offload can put prompts, latents, masks, smoothing kernels, and inversion noise on the wrong device, causing device mismatches or unexpectedly running heavy work on CPU.
Reproduction:
importtorchfromdiffusersimportLEditsPPPipelineStableDiffusionXLfromtests.pipelines.ledits_pp.test_ledits_pp_stable_diffusion_xlimportLEditsPPPipelineStableDiffusionXLFastTestsifnottorch.cuda.is_available():
raiseSystemExit("requires CUDA to exercise model CPU offload")
case=LEditsPPPipelineStableDiffusionXLFastTests()
pipe=LEditsPPPipelineStableDiffusionXL(**case.get_dummy_components())
pipe.enable_model_cpu_offload()
print(pipe.device, pipe._execution_device) # CPU vs CUDA under offloadinputs=case.get_dummy_inversion_inputs("cpu")
inputs.update({"image": inputs["image"][0], "num_inversion_steps": 2, "skip": 0.0})
pipe.invert(**inputs)
pipe(editing_prompt="cat", output_type="latent", use_cross_attn_mask=True)Relevant precedent:
| device=self._execution_device |
| device=self._execution_device |
| device=self._execution_device |
Suggested fix:
At the start of __call__() and invert(), set device = self._execution_device and use that local for tensor movement/allocation. Pass device into VAE image encoding helpers instead of reading self.device inside them.
ledits_ppmodel/pipeline reviewCommit tested:
0f1abc4ae8b0eb2a3b40e82a310507281144c423Review performed against the repository review rules.
Coverage checked: fast and slow test classes exist for both SD and SDXL LEdits++; fast tests passed locally with
.venv(3 passed, 1 skippedfor each file). Slow tests exist but were not run. Duplicate search was run withgh search issues/prs; no duplicates found for the issues below. Existing open issue #8826 covers a separate known LEdits++ empty attention-store crash, so I am not presenting that one as new.Issue 1:
LEditsPPInversionPipelineOutputis missing from lazy exportsAffected code:
diffusers/src/diffusers/pipelines/ledits_pp/__init__.py
Lines 24 to 27 in 0f1abc4
Problem:
The lazy import structure exports
LEditsPPDiffusionPipelineOutputtwice and omitsLEditsPPInversionPipelineOutput. The eagerTYPE_CHECKINGpath imports both outputs, so behavior differs between slow/eager imports and normal lazy imports.diffusers.pipelines.__init__also imports these outputs in the eager path but does not expose them in the lazy import structure.Impact:
Public imports documented under
pipelines.ledits_ppfail at runtime for the inversion output class.Reproduction:
Relevant precedent:
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_output.py
Lines 27 to 42 in 0f1abc4
Suggested fix:
Also either add both output classes to
src/diffusers/pipelines/__init__.pylazy exports or remove the eager-only imports there.Issue 2: SDXL editing after batched inversion has inconsistent embedding batch sizes
Affected code:
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 538 to 558 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 1112 to 1121 in 0f1abc4
Problem:
LEditsPPPipelineStableDiffusionXL.encode_prompt()encodes edit concepts once per concept, not once per(concept, image)pair. After batched inversion, latents are repeated by1 + enabled_editing_prompts, but edit prompt embeddings and pooled embeddings are not expanded the same way.Impact:
SDXL LEdits++ cannot edit a batch of inverted images with multiple edit prompts. The failure is currently untested because fast tests cover batched inversion only, not batched editing.
Reproduction:
Relevant precedent:
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion.py
Lines 632 to 645 in 0f1abc4
Suggested fix:
Expand edit concept embeddings per image in
__call__orencode_prompt, while keepingnum_edit_tokensindexed per concept. The implementation should preserve the inversion path, whereediting_promptis reused as the source prompt and already represents one prompt per image.Issue 3: SDXL non-square image sizes are swapped for micro-conditioning
Affected code:
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 1574 to 1576 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 1103 to 1110 in 0f1abc4
Problem:
invert()computeswidthfrom latent height andheightfrom latent width, then storesself.size = (height, width). For non-square images, SDXL added time IDs receive swapped original/target sizes. Thetarget_sizeargument accepted by__call__is also ignored; the call always usesself.size.Impact:
Non-square SDXL edits are conditioned on the wrong dimensions, which can degrade output and makes the documented
target_sizeparameter ineffective.Reproduction:
Relevant precedent:
diffusers/src/diffusers/pipelines/stable_diffusion_xl/pipeline_stable_diffusion_xl.py
Lines 1030 to 1031 in 0f1abc4
Suggested fix:
Then use
target_size = target_size or self.sizein__call__.Issue 4: Callback tensor allowlists include names that are not in scope
Affected code:
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion.py
Lines 301 to 304 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion.py
Lines 1235 to 1243 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 336 to 344 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 1016 to 1026 in 0f1abc4
Problem:
The SD pipeline allows
prompt_embeds, but the denoising loop has noprompt_embedslocal. The SDXL pipeline allowsnegative_add_time_ids, but that local is commented out and never defined; SDXL also comments out itscheck_inputs()call, so invalid callback tensor names are not rejected early.Impact:
Documented callback customization crashes with
KeyErrorinstead of either passing the requested tensor or raising the normal validation error.Reproduction:
Relevant precedent:
diffusers/src/diffusers/pipelines/stable_diffusion_xl/pipeline_stable_diffusion_xl.py
Lines 232 to 237 in 0f1abc4
diffusers/src/diffusers/pipelines/stable_diffusion_xl/pipeline_stable_diffusion_xl.py
Lines 636 to 640 in 0f1abc4
Suggested fix:
Make
_callback_tensor_inputsmatch actual denoising-loop locals, callcheck_inputs()in SDXL, and add focused fast tests for every allowed callback tensor.Issue 5: SDXL IP-Adapter path calls the VAE image encoder instead of IP-Adapter encoding
Affected code:
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 1123 to 1128 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 1440 to 1473 in 0f1abc4
Problem:
__call__()acceptsip_adapter_image, but callsself.encode_image(ip_adapter_image, device, num_images_per_prompt). In this pipeline,encode_image()is the VAE inversion helper, not the CLIP/IP-Adapter image encoder from SDXL. The positional arguments are interpreted asdtypeandheight.Impact:
The advertised IP-Adapter path is unusable and fails before preparing image embeddings.
Reproduction:
Relevant precedent:
diffusers/src/diffusers/pipelines/stable_diffusion_xl/pipeline_stable_diffusion_xl.py
Lines 522 to 590 in 0f1abc4
diffusers/src/diffusers/pipelines/stable_diffusion_xl/pipeline_stable_diffusion_xl.py
Lines 1153 to 1160 in 0f1abc4
Suggested fix:
Rename the VAE helper to something like
encode_vae_image(), restore/copy SDXL’sencode_image()andprepare_ip_adapter_image_embeds(), addip_adapter_image_embeds, and includeimage_encoderin the offload sequence.Issue 6:
invert()and cross-attention masking permanently replace user attention processorsAffected code:
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion.py
Lines 497 to 519 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion.py
Lines 1343 to 1344 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 811 to 834 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 1543 to 1544 in 0f1abc4
Problem:
invert()unconditionally callsself.unet.set_attn_processor(AttnProcessor()).prepare_unet()also replaces all processors with eitherLEDITSCrossAttnProcessoror plainAttnProcessor(). Neither path restores the original processors.Impact:
Any configured attention backend or custom processor, including
AttnProcessor2_0, xFormers-style processors, LoRA/IP-Adapter processors, or user-supplied processors, is silently discarded after inversion or masked editing.Reproduction:
Relevant precedent:
diffusers/src/diffusers/models/attention_processor.py
Lines 2696 to 2786 in 0f1abc4
Suggested fix:
Store the original
unet.attn_processorsbefore installing LEdits processors and restore them in afinallyblock after the temporary operation. For non-intercepted attention layers, preserve the existing processor instead of replacing it with bareAttnProcessor().Issue 7: CPU offload paths use
self.deviceinstead of_execution_deviceAffected code:
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion.py
Lines 948 to 995 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion.py
Lines 1388 to 1398 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 1010 to 1014 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 1459 to 1467 in 0f1abc4
diffusers/src/diffusers/pipelines/ledits_pp/pipeline_leditspp_stable_diffusion_xl.py
Lines 1650 to 1655 in 0f1abc4
Problem:
Both pipelines declare
model_cpu_offload_seq, but many runtime tensors are moved toself.device. Underenable_model_cpu_offload(),self.devicecan remain CPU while_execution_deviceis the actual accelerator. Standard pipelines use_execution_deviceat call time for this reason.Impact:
CPU offload can put prompts, latents, masks, smoothing kernels, and inversion noise on the wrong device, causing device mismatches or unexpectedly running heavy work on CPU.
Reproduction:
Relevant precedent:
diffusers/src/diffusers/pipelines/stable_diffusion/pipeline_stable_diffusion.py
Line 955 in 0f1abc4
diffusers/src/diffusers/pipelines/latent_consistency_models/pipeline_latent_consistency_text2img.py
Line 786 in 0f1abc4
diffusers/src/diffusers/pipelines/latent_consistency_models/pipeline_latent_consistency_img2img.py
Line 849 in 0f1abc4
Suggested fix:
At the start of
__call__()andinvert(), setdevice = self._execution_deviceand use that local for tensor movement/allocation. Passdeviceinto VAE image encoding helpers instead of readingself.deviceinside them.