shunk031 commited on
Commit
30cbfdc
·
verified ·
1 Parent(s): 3d958ad

Publish Collage Diffusion pipeline at 9fff847022ee

Browse files
Files changed (2) hide show
  1. README.md +490 -4
  2. pipeline.py +807 -0
README.md CHANGED
@@ -1,7 +1,493 @@
1
- # Collage Diffusion source assets
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- Apart from this model card and repository metadata, the only content currently published here is the original source material linked by the authors of [Collage Diffusion](https://github.com/VSAnimator/collage-diffusion), archived under [`source-assets/`](https://huggingface.co/creative-graphic-design/collage-diffusion/tree/main/source-assets) with a provenance and checksum inventory.
4
 
5
- Publishing the pipeline configuration is a later step and requires separate approval. No pipeline configuration or model weights are published here.
6
 
7
- The files under `source-assets/` have no clearly stated per-file license. The Apache-2.0 metadata on the separate `creative-graphic-design/documentation-images` dataset does not apply to these files. This repository therefore does not declare license metadata for these assets.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - en
4
+ library_name: "diffusers"
5
+ pipeline_tag: "image-to-image"
6
+ tags:
7
+ - "collage-diffusion"
8
+ - "image-generation"
9
+ - "wacv-2024"
10
+ datasets: []
11
+ base_model: "sd2-community/stable-diffusion-2-1-base"
12
+ ---
13
 
14
+ <!-- --8<-- [start:card] -->
15
 
16
+ # Model Card for Collage Diffusion
17
 
18
+ [![arXiv](https://img.shields.io/static/v1?label=arXiv&message=2303.00262&color=b31b1b&style=flat-square&logo=arxiv&logoColor=white)](https://arxiv.org/abs/2303.00262)
19
+ ![venue](https://img.shields.io/static/v1?label=venue&message=WACV+2024&color=purple&style=flat-square)
20
+ ![license](https://img.shields.io/static/v1?label=license&message=Apache-2.0&color=green&style=flat-square&logo=apache&logoColor=white)
21
+ ![framework](https://img.shields.io/static/v1?label=framework&message=diffusers&color=red&style=flat-square&logo=huggingface&logoColor=white)
22
+ ![base](https://img.shields.io/static/v1?label=base&message=diffusers&color=blue&style=flat-square&logo=huggingface&logoColor=white)
23
+ ![task](https://img.shields.io/static/v1?label=task&message=image-to-image&color=purple&style=flat-square)
24
+ ![vendor-parity](https://img.shields.io/static/v1?label=vendor-parity&message=tolerance-verified&color=success&style=flat-square)
25
+ ![hub](https://img.shields.io/static/v1?label=hub&message=published-code-only&color=brightgreen&style=flat-square&logo=huggingface&logoColor=white)
26
+
27
+ This package exposes Collage Diffusion as a [`🧨diffusers`](https://huggingface.co/docs/diffusers/index) pipeline that harmonizes text-described RGBA layers into a single image.
28
+
29
+ ## Model Details
30
+
31
+ ### Model Description
32
+
33
+ Collage Diffusion combines a scene prompt with ordered RGBA image layers. Visible alpha masks guide cross-attention to each layer's text, while per-layer noise levels control how much of each source layer is preserved during diffusion. The pipeline can load caller-provided textual-inversion embeddings. It uses the upstream Stable Diffusion 2.1 base checkpoint and does not train, store, or publish model-specific weights.
34
+
35
+ - **Developed by:** Vishnu Sarukkai, Linden Li, Arden Ma, Christopher Ré, and Kayvon Fatahalian.
36
+ - **Shared by:** creative-graphic-design.
37
+ - **Model type:** image generation; task: image-to-image; conditioning: layers, text.
38
+ - **Language(s) (NLP):** English scene and layer prompts.
39
+ - **License:** Apache-2.0 for this package; the base checkpoint and input assets retain their own terms.
40
+ - **Finetuned from:** No Collage Diffusion checkpoint; inference loads `sd2-community/stable-diffusion-2-1-base` at the pinned revision below under the CreativeML Open RAIL++-M terms declared by its model card.
41
+
42
+ ### Model Sources
43
+
44
+ - **Repository:** [Collage Diffusion source repository](https://github.com/VSAnimator/collage-diffusion); it has no declared repository license. Its notebook is used as a behavioral reference; its code is not included here. Notebook-linked image layers and textual-inversion embeddings are archived, with source links and integrity records, in the [model repository's source-assets directory](https://huggingface.co/creative-graphic-design/collage-diffusion/tree/main/source-assets).
45
+ - **Paper:** [Collage Diffusion](https://arxiv.org/abs/2303.00262).
46
+ - **Related implementation:** [Collage Diffusion UI](https://github.com/linden-li/collage-diffusion-ui) was inspected as a secondary reference. Its repository also has no declared license; its code is not included here.
47
+ - **Released weights:** The package does not contain its own weights. It loads the [Stable Diffusion 2.1 base mirror](https://huggingface.co/sd2-community/stable-diffusion-2-1-base) at a pinned revision.
48
+
49
+ ### Base Checkpoint Provenance and Integrity
50
+
51
+ The authors' notebook names `stabilityai/stable-diffusion-2-1-base`, but that Hub repository now returns HTTP 404. The comparison and package examples use `sd2-community/stable-diffusion-2-1-base` at revision `4e63672c03103b6c636b8fb4119ba982469b2955`. The model card declares the CreativeML Open RAIL++-M license; users must review and follow those terms.
52
+
53
+ The main weight files were checked across three available copies. Their SHA-256 values match byte-for-byte:
54
+
55
+ | Copy | Revision | UNet SHA-256 | VAE SHA-256 | Text encoder SHA-256 |
56
+ | -------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
57
+ | `sd2-community/stable-diffusion-2-1-base` | `4e63672c03103b6c636b8fb4119ba982469b2955` | `6dfae3e5f7d459b50f4b0850ead945972c75bb0e1897628933e169eb43974214` | `a1d993488569e928462932c8c38a0760b874d166399b14414135bd9c42df5815` | `cce6febb0b6d876ee5eb24af35e27e764eb4f9b1d0b7c026c8c3333d4cfc916c` |
58
+ | `Manojb/stable-diffusion-2-1-base` | `0094d483a120f3f33dafbd187ea4aa60d10de75c` | same | same | same |
59
+ | `LanguageMachines/stable-diffusion-2-1-base` | `2579f99c90b5a3019fc115ee9566d5ec4f17fccf` | same | same | same |
60
+
61
+ SHA-256 values for every file used from the selected pinned snapshot:
62
+
63
+ | File | SHA-256 |
64
+ | -------------------------------------------- | ------------------------------------------------------------------ |
65
+ | `model_index.json` | `518553e098fdaa4153d9a92139e344e44f53bb329c22f383293e3099c4f1bad2` |
66
+ | `feature_extractor/preprocessor_config.json` | `2a1da83b5e1032aaeef397552ddb408dca0d8cd1dc58f61bf6abf38d6f33a0a2` |
67
+ | `scheduler/scheduler_config.json` | `11ac5627d7df0fa344b875c4b5722b1767a8a2aa1684c2cf8b4d614300127234` |
68
+ | `text_encoder/config.json` | `7e33bfc475f6cf76e69b28b101993ca834d1f22ff4d35caefde32d63d4f42fba` |
69
+ | `text_encoder/model.safetensors` | `cce6febb0b6d876ee5eb24af35e27e764eb4f9b1d0b7c026c8c3333d4cfc916c` |
70
+ | `tokenizer/merges.txt` | `9fd691f7c8039210e0fced15865466c65820d09b63988b0174bfe25de299051a` |
71
+ | `tokenizer/special_tokens_map.json` | `f118ab3a983206e4f32583448de6bd6aae4ee21869135cef1f5848a753cdaab6` |
72
+ | `tokenizer/tokenizer_config.json` | `d562b18a0c6d32a168fce3d7a342fce64579ebacffb98fb71b0affc494fc32d8` |
73
+ | `tokenizer/vocab.json` | `e089ad92ba36837a0d31433e555c8f45fe601ab5c221d4f607ded32d9f7a4349` |
74
+ | `unet/config.json` | `ce0c6d379e3b1d3e1f79338de70c80a1e36f17a6372439a8249b6fb0dfa1b608` |
75
+ | `unet/diffusion_pytorch_model.safetensors` | `6dfae3e5f7d459b50f4b0850ead945972c75bb0e1897628933e169eb43974214` |
76
+ | `vae/config.json` | `424117cb534ce03497c41305ed868980123917b2b6abba4bbaa615e968772903` |
77
+ | `vae/diffusion_pytorch_model.safetensors` | `a1d993488569e928462932c8c38a0760b874d166399b14414135bd9c42df5815` |
78
+
79
+ Both sides of the notebook comparison use this same pinned snapshot and the file hashes listed above. The selected model copy and its weights are not redistributed by this package.
80
+
81
+ ## Supported Checkpoints
82
+
83
+ | Artifact | Hub ID | Status |
84
+ | ------------------------- | ------------------------------------------- | ----------------------------------------- |
85
+ | `diffusers` pipeline code | `creative-graphic-design/collage-diffusion` | planned, code only; no learned checkpoint |
86
+
87
+ When publishing this pipeline to the Hub, its `model_index.json` and `scheduler/scheduler_config.json` must specify `EulerAncestralDiscreteScheduler`.
88
+
89
+ ## Uses
90
+
91
+ ### Direct Use
92
+
93
+ Use the pipeline to harmonize a layered collage while guiding object placement through masks and controlling source preservation through per-layer noise levels.
94
+
95
+ ```python
96
+ from PIL import Image
97
+ from diffusers import EulerAncestralDiscreteScheduler
98
+ from collage_diffusion import CollageDiffusionPipeline, CollageLayer
99
+
100
+ base = "sd2-community/stable-diffusion-2-1-base"
101
+ revision = "4e63672c03103b6c636b8fb4119ba982469b2955"
102
+ # Cross-attention guidance uses the scheduler's sigma values.
103
+ scheduler = EulerAncestralDiscreteScheduler.from_pretrained(
104
+ base, subfolder="scheduler", revision=revision
105
+ )
106
+ pipe = CollageDiffusionPipeline.from_pretrained(
107
+ base, revision=revision, scheduler=scheduler
108
+ )
109
+ layers = [
110
+ CollageLayer(
111
+ image=Image.open("toy.png").convert("RGBA"),
112
+ prompt="a small wooden toy",
113
+ position=(64, 96),
114
+ noise_level=0.35,
115
+ positive_attention_weight=0.5,
116
+ negative_attention_weight=0.5,
117
+ ),
118
+ ]
119
+ result = pipe(
120
+ prompt="a small wooden toy on a studio table",
121
+ layers=layers,
122
+ negative_prompt="collage",
123
+ seed=0,
124
+ num_inference_steps=20,
125
+ )
126
+ result.images[0].save("harmonized.png")
127
+ print("Saved harmonized.png")
128
+ ```
129
+
130
+ ```text
131
+ Saved harmonized.png
132
+ ```
133
+
134
+ Textual-inversion embeddings are caller-supplied with a token-to-file mapping, and the matching token is set on its `CollageLayer`. ControlNet and textual-inversion training are outside this package's current scope.
135
+
136
+ ### Examples
137
+
138
+ #### Shared setup
139
+
140
+ These examples follow the [Collage Diffusion notebook](https://github.com/VSAnimator/collage-diffusion/blob/main/CollageDiffusionDemo.ipynb) and its [Google Drive image layers](https://drive.google.com/drive/folders/1EfoLfWbn5fl7c092uk96zHtJOVHRlIqI?usp=sharing) and [textual-inversion assets](https://drive.google.com/drive/folders/13Gg8y-0FxdPcuhrPOUw6Ze9wa0Ruu-cI?usp=sharing). Because the Drive files may disappear, copies and their source URLs, retrieval dates, sizes, and SHA-256 values are recorded in the [model repository's source-assets directory](https://huggingface.co/creative-graphic-design/collage-diffusion/tree/main/source-assets). The original author, rights holder, and per-file license are unknown; the documentation-images dataset's Apache-2.0 license does not apply to those files.
141
+
142
+ Each image places the input collage on the left and the generated result on the right. All scenes use `sd2-community/stable-diffusion-2-1-base` at revision `4e63672c03103b6c636b8fb4119ba982469b2955`, `EulerAncestralDiscreteScheduler`, seed `0`, 512 × 512 resolution, guidance scale `7.5`, and 50 requested inference steps. The active step count is 40 at strength `0.8` and 37 at `0.75`. The base checkpoint uses the [CreativeML Open RAIL++-M License](https://huggingface.co/sd2-community/stable-diffusion-2-1-base). The code uses every CPU thread available to its runtime; the generated examples used **2 threads**.
143
+
144
+ #### Toy Box
145
+
146
+ Prompt: `a teddy bear, a wood train, and an american football, in front of a tan background`. Negative prompt: `collage`. Strength: `0.8`; `noise_blur=20`; 50 requested (40 active) steps.
147
+
148
+ ![Toy Box input collage on the left and Collage Diffusion output on the right.](https://huggingface.co/datasets/creative-graphic-design/documentation-images/resolve/main/collage-diffusion/toy-box-diffusers-comparison-99b4a70.png)
149
+
150
+ #### Bento Box
151
+
152
+ Prompt: `a bento box with rice, edamame, ginger, and sushi`. Negative prompt: `collage`. Strength: `0.8`; `noise_blur=20`; 50 requested (40 active) steps.
153
+
154
+ ![Bento Box input collage on the left and Collage Diffusion output on the right.](https://huggingface.co/datasets/creative-graphic-design/documentation-images/resolve/main/collage-diffusion/bento-box-diffusers-comparison-99b4a70.png)
155
+
156
+ #### Cake Table
157
+
158
+ Prompt: `a wood table with two white chairs behind, two pink cakes on top, maroon bookshelves behind, and winter window`. Negative prompt: `collage, warped, cupcake`. Strength: `0.75`; `noise_blur=10`; 50 requested (37 active) steps.
159
+
160
+ ![Cake Table input collage on the left and Collage Diffusion output on the right.](https://huggingface.co/datasets/creative-graphic-design/documentation-images/resolve/main/collage-diffusion/cake-table-diffusers-comparison-99b4a70.png)
161
+
162
+ The following script builds each scene from its archived image layers and textual-inversion embeddings, then saves the side-by-side examples above. It generates all three by default; set `COLLAGE_SCENES` to a comma-separated subset of scene names to run only selected examples.
163
+
164
+ ```python
165
+ import gc
166
+ import os
167
+ import time
168
+
169
+ import torch
170
+ from diffusers import EulerAncestralDiscreteScheduler
171
+ from diffusers.utils import load_image, make_image_grid
172
+ from huggingface_hub import hf_hub_download
173
+
174
+ from collage_diffusion import CollageDiffusionPipeline, CollageLayer, compose_collage_layers
175
+
176
+
177
+ torch.set_num_threads(os.cpu_count() or 1)
178
+ print(f"CPU threads: {torch.get_num_threads()}")
179
+
180
+ base = "sd2-community/stable-diffusion-2-1-base"
181
+ base_revision = "4e63672c03103b6c636b8fb4119ba982469b2955"
182
+ asset_repo_id = "creative-graphic-design/collage-diffusion"
183
+ asset_revision = "3d958ad645d9d4641126c8d8a3e74116f4a99421"
184
+ asset_root = "source-assets"
185
+ asset_url = "https://huggingface.co/creative-graphic-design/collage-diffusion/resolve/3d958ad645d9d4641126c8d8a3e74116f4a99421/source-assets"
186
+
187
+ scenes = [
188
+ {
189
+ "name": "Toy Box",
190
+ "folder": "toyBox2",
191
+ "output": "toy-box-diffusers-comparison-99b4a70.png",
192
+ "prompt": "a teddy bear, a wood train, and an american football, in front of a tan background",
193
+ "negative_prompt": "collage",
194
+ "strength": 0.8,
195
+ "noise_blur": 20,
196
+ "layers": [
197
+ ("_0004_background.png", "background", 0.8, 0.0, 0.5, None, None),
198
+ ("_0003_football.png", "american football", 0.2, 0.5, 0.5, None, None),
199
+ ("_0002_teddy_bear.png", "teddy bear", 0.8, 0.2, 0.5, "<fda>", "asset_library/toyBox2-teddy_bear/lora_weight_e999_s1000.ti.pt"),
200
+ ("_0000_wood_train.png", "wood train", 0.5, 0.2, 0.5, "<xtc>", "asset_library/toyBox2-wood_train/lora_weight_e999_s1000.ti.pt"),
201
+ ],
202
+ },
203
+ {
204
+ "name": "Bento Box",
205
+ "folder": "bentoBox",
206
+ "output": "bento-box-diffusers-comparison-99b4a70.png",
207
+ "prompt": "a bento box with rice, edamame, ginger, and sushi",
208
+ "negative_prompt": "collage",
209
+ "strength": 0.8,
210
+ "noise_blur": 20,
211
+ "layers": [
212
+ ("_0004_bento_box.png", "bento box", 0.5, 0.0, 0.5, "<krk>", "asset_library/bentoBox-bento_box/lora_weight_e999_s1000.ti.pt"),
213
+ ("_0003_rice.png", "rice", 0.5, 0.4, 0.5, "<lol>", "asset_library/bentoBox-rice/lora_weight_e499_s500.ti.pt"),
214
+ ("_0002_edamame.png", "edamame", 0.6, 0.0, 0.5, "<fda>", "asset_library/bentoBox-edamame/lora_weight_e999_s1000.ti.pt"),
215
+ ("_0001_ginger.png", "ginger", 0.8, 0.6, 0.5, "<xtc>", "asset_library/bentoBox-ginger/lora_weight_e999_s1000.ti.pt"),
216
+ ("_0000_sushi.png", "sushi", 0.8, 0.2, 0.5, "<kyt>", "asset_library/bentoBox-sushi/lora_weight_e999_s1000.ti.pt"),
217
+ ],
218
+ },
219
+ {
220
+ "name": "Cake Table",
221
+ "folder": "cakeTable2",
222
+ "output": "cake-table-diffusers-comparison-99b4a70.png",
223
+ "prompt": "a wood table with two white chairs behind, two pink cakes on top, maroon bookshelves behind, and winter window",
224
+ "negative_prompt": "collage, warped, cupcake",
225
+ "strength": 0.75,
226
+ "noise_blur": 10,
227
+ "layers": [
228
+ ("_0006_winter_window.png", "winter window", 0.8, 0.45, 1.0, "<krk>", "asset_library/cakeTable2-winter_window/lora_weight_e499_s500.ti.pt"),
229
+ ("_0005_maroon_bookshelves.png", "maroon bookshelves", 0.8, 0.35, 1.0, "<lol>", "asset_library/cakeTable2-maroon_bookshelves/lora_weight_e999_s1000.ti.pt"),
230
+ ("_0004_white_chairs.png", "white chairs", 0.8, 0.5, 1.0, "<fda>", "asset_library/cakeTable2-white_chairs/lora_weight_e999_s1000.ti.pt"),
231
+ ("_0003_wood_table.png", "wood table", 0.6, 0.1, 1.0, "<xtc>", "asset_library/cakeTable2-wood_table/lora_weight_e999_s1000.ti.pt"),
232
+ ("_0002_decorated_cakes.png", "pink cakes", 0.6, 0.3, 2.0, "<kyt>", "asset_library/cakeTable2-decorated_cakes/lora_weight_e99_s100.ti.pt"),
233
+ ],
234
+ },
235
+ ]
236
+
237
+ selected_names = os.environ.get("COLLAGE_SCENES")
238
+ if selected_names:
239
+ selected_names = {name.strip() for name in selected_names.split(",")}
240
+ scenes = [scene for scene in scenes if scene["name"] in selected_names]
241
+ if not scenes:
242
+ raise ValueError("COLLAGE_SCENES must name at least one configured scene")
243
+
244
+ for scene in scenes:
245
+ scene_started = time.perf_counter()
246
+ layers = []
247
+ textual_inversion_embeddings = {}
248
+ for filename, prompt, noise, positive, negative, token, embedding in scene["layers"]:
249
+ layers.append(
250
+ CollageLayer(
251
+ image=load_image(
252
+ f"{asset_url}/image_layers/{scene['folder']}/layers/{filename}",
253
+ convert_method=lambda image: image.convert("RGBA"),
254
+ ),
255
+ prompt=prompt,
256
+ noise_level=noise,
257
+ positive_attention_weight=positive,
258
+ negative_attention_weight=negative,
259
+ textual_inversion_token=token,
260
+ )
261
+ )
262
+ if token is not None and embedding is not None:
263
+ textual_inversion_embeddings[token] = hf_hub_download(
264
+ repo_id=asset_repo_id,
265
+ repo_type="model",
266
+ revision=asset_revision,
267
+ filename=f"{asset_root}/{embedding}",
268
+ )
269
+
270
+ input_collage, _ = compose_collage_layers(layers, width=512, height=512)
271
+ scheduler = EulerAncestralDiscreteScheduler.from_pretrained(
272
+ base, subfolder="scheduler", revision=base_revision
273
+ )
274
+ pipe = CollageDiffusionPipeline.from_pretrained(
275
+ base, revision=base_revision, scheduler=scheduler
276
+ )
277
+ result = pipe(
278
+ prompt=scene["prompt"],
279
+ layers=layers,
280
+ textual_inversion_embeddings=textual_inversion_embeddings,
281
+ negative_prompt=scene["negative_prompt"],
282
+ strength=scene["strength"],
283
+ num_inference_steps=50,
284
+ guidance_scale=7.5,
285
+ noise_blur=scene["noise_blur"],
286
+ seed=0,
287
+ ).images[0]
288
+ make_image_grid([input_collage, result], rows=1, cols=2).save(scene["output"])
289
+ elapsed_minutes = (time.perf_counter() - scene_started) / 60
290
+ print(f"Saved {scene['output']} in {elapsed_minutes:.1f} minutes")
291
+ del pipe, scheduler, result, input_collage, layers, textual_inversion_embeddings
292
+ gc.collect()
293
+ ```
294
+
295
+ ### Downstream Use
296
+
297
+ Generated images may be used for design exploration after human review of object placement, appearance, and the licenses attached to all input layers and model weights.
298
+
299
+ ### Out-of-Scope Use
300
+
301
+ This pipeline does not guarantee semantic accuracy, rights clearance for caller-provided images or embeddings, or suitability for safety-critical applications.
302
+
303
+ ## Bias, Risks, and Limitations
304
+
305
+ Results inherit the capabilities and biases of Stable Diffusion 2.1 and the caller's prompts, layers, masks, and textual-inversion embeddings. The original authors and rights holders of the notebook-linked example images and embeddings, and the license for each file, are unknown. The [source-assets inventory](https://huggingface.co/creative-graphic-design/collage-diffusion/blob/main/source-assets/README.md) records the retrieval URLs and integrity data. The paper's ControlNet extension and textual-inversion training are not implemented here.
306
+
307
+ ### Recommendations
308
+
309
+ Review each output and confirm rights to every input layer, embedding, and generated asset before publication or commercial use.
310
+
311
+ ## How to Get Started with the Model
312
+
313
+ Install the pipeline package and `laygen` from commit-pinned GitHub source archives. The repository README uses the `main` archive URL so the command stays current; the published Hub card pins both packages to the commit that published its pipeline code. The archive includes the selected package directory without fetching vendor submodules.
314
+
315
+ ```bash
316
+ pip install \
317
+ "laygen @ https://github.com/creative-graphic-design/design-generators/archive/9fff847022eeff922429df2fdd299e21408a0a40.zip#subdirectory=lib/laygen" \
318
+ "collage-diffusion @ https://github.com/creative-graphic-design/design-generators/archive/9fff847022eeff922429df2fdd299e21408a0a40.zip#subdirectory=models/collage-diffusion"
319
+ ```
320
+
321
+ The Hub pipeline loads code from `creative-graphic-design/collage-diffusion` and weights from `sd2-community/stable-diffusion-2-1-base`. Before setting `trust_remote_code=True`, review the pipeline code in the [Collage Diffusion Hub repository](https://huggingface.co/creative-graphic-design/collage-diffusion); `diffusers` executes that code when it loads the custom pipeline.
322
+
323
+ ```python
324
+ from PIL import Image
325
+ from diffusers import DiffusionPipeline
326
+ from collage_diffusion import CollageLayer
327
+
328
+ base = "sd2-community/stable-diffusion-2-1-base"
329
+ base_revision = "4e63672c03103b6c636b8fb4119ba982469b2955"
330
+ pipe = DiffusionPipeline.from_pretrained(
331
+ base,
332
+ revision=base_revision,
333
+ custom_pipeline="creative-graphic-design/collage-diffusion",
334
+ trust_remote_code=True,
335
+ )
336
+ layer = CollageLayer(
337
+ image=Image.open("toy.png").convert("RGBA"),
338
+ prompt="a small wooden toy",
339
+ position=(64, 96),
340
+ noise_level=0.35,
341
+ positive_attention_weight=0.5,
342
+ negative_attention_weight=0.5,
343
+ )
344
+ result = pipe(
345
+ prompt="a small wooden toy on a studio table",
346
+ layers=[layer],
347
+ negative_prompt="collage",
348
+ seed=0,
349
+ num_inference_steps=20,
350
+ )
351
+ result.images[0].save("harmonized.png")
352
+ print(type(pipe).__name__)
353
+ ```
354
+
355
+ ```text
356
+ CollageDiffusionPipeline
357
+ ```
358
+
359
+ The Hub card pins both source archives to the repository commit that published the pipeline. This package has no learned checkpoints; the pipeline reads base weights from the upstream Stable Diffusion repository and accepts caller-provided layers and optional textual-inversion embeddings.
360
+
361
+ ```bash
362
+ git clone https://github.com/creative-graphic-design/design-generators.git
363
+ cd design-generators
364
+ uv sync --package collage-diffusion
365
+ uv run --package collage-diffusion python
366
+ ```
367
+
368
+ ```python
369
+ from diffusers import EulerAncestralDiscreteScheduler
370
+ from collage_diffusion import CollageDiffusionPipeline
371
+
372
+ base = "sd2-community/stable-diffusion-2-1-base"
373
+ revision = "4e63672c03103b6c636b8fb4119ba982469b2955"
374
+ # Cross-attention guidance uses the scheduler's sigma values.
375
+ scheduler = EulerAncestralDiscreteScheduler.from_pretrained(
376
+ base, subfolder="scheduler", revision=revision
377
+ )
378
+ pipe = CollageDiffusionPipeline.from_pretrained(
379
+ base, revision=revision, scheduler=scheduler
380
+ )
381
+ print(type(pipe).__name__)
382
+ ```
383
+
384
+ ## Training Details
385
+
386
+ ### Training Data
387
+
388
+ No training dataset is packaged. Layer images and optional textual-inversion embeddings are caller inputs.
389
+
390
+ ### Training Procedure
391
+
392
+ This package contains no training procedure and no Collage Diffusion checkpoint.
393
+
394
+ #### Training Hyperparameters
395
+
396
+ - **Training regime:** Not applicable; the pipeline uses an upstream inference checkpoint.
397
+
398
+ ## Evaluation
399
+
400
+ ### Testing Data, Factors & Metrics
401
+
402
+ #### Testing Data
403
+
404
+ Ordinary tests use small synthetic RGBA layers. The comparison uses the Bento Box, Cake Table, and Toy Box scenes and their notebook-linked embeddings; public archive copies are linked above.
405
+
406
+ #### Factors
407
+
408
+ The reference comparison holds the input layers, preprocessing, textual-inversion embedding, scheduler, seed, and number of denoising steps constant between the notebook path and this package.
409
+
410
+ #### Metrics
411
+
412
+ Compare normalized final RGB arrays with RMSE, maximum absolute difference, and PSNR, and compare each post-scheduler, post-preservation latent with RMSE. The six comparisons use three 512 × 512 scenes and seeds 0 and 1, the same pinned checkpoint, each scene's layers and embeddings, Euler Ancestral, and 10 requested steps (7 or 8 active steps according to strength). Bento Box and Toy Box use strength `0.8` with `noise_blur=20`; Cake Table uses strength `0.75` with `noise_blur=10`.
413
+
414
+ The notebook example omits `num_inference_steps`, so it uses the pipeline default of 50 steps (40 active at strength `0.8` and 37 at `0.75`). The parity run requests 10 steps to limit comparison cost (8 or 7 active); the notebook and package use identical settings within each comparison pair.
415
+
416
+ ### Parity Results
417
+
418
+ An independent acceptance rerun passed all six scene-and-seed conditions against the authors' notebook. This is tolerance-verified agreement, not bitwise equality. The reference uses `diffusers` `0.10.0`, Torch `2.0.0+cpu`, torchvision `0.15.1+cpu`, and [`🤗transformers`](https://huggingface.co/docs/transformers/index) `4.28.1`; the package uses `diffusers` `0.39.0`, Torch `2.13.0+cpu`, torchvision `0.28.0+cpu`, and `transformers` `5.13.0`. Both comparison environments used CPU with one thread. The ordinary Stable Diffusion Img2Img controls and comparison plan are recorded in [issue #484](https://github.com/creative-graphic-design/design-generators/issues/484).
419
+
420
+ Each threshold uses `T = 2·Bmax − Bmedian` over the six ordinary Stable Diffusion controls: RGB RMSE has `Bmax=6.00713e-5`, `Bmedian=1.61880e-5`, and `T=1.03955e-4`; maximum per-step latent RMSE has `Bmax=1.01644e-4`, `Bmedian=4.42415e-5`, and `T=1.59046e-4`.
421
+
422
+ Each RMSE is compared with zero using `rtol=0`; the RGB criterion uses `atol=1.03955e-4` and the latent criterion uses `atol=1.59046e-4`.
423
+
424
+ The implementation-run measurements below are recorded in this [issue #484 comment](https://github.com/creative-graphic-design/design-generators/issues/484#issuecomment-6636816601). Each row reports the final normalized RGB RMSE, PSNR (peak 1.0), maximum absolute RGB difference, maximum per-step post-scheduler/post-preservation latent RMSE, and first U-Net output difference (maximum absolute difference and RMSE).
425
+
426
+ | Scene | Seed | Active steps | RGB RMSE | PSNR (dB) | RGB max abs | Max per-step latent RMSE | First U-Net max abs | First U-Net RMSE |
427
+ | ---------- | ---: | -----------: | -----------------: | --------------: | -----------------: | -----------------------: | ------------------: | -----------------: |
428
+ | Bento Box | 0 | 8 | `3.00031813940e-5` | `90.4566538465` | `4.99486923218e-4` | `6.88358887783e-5` | `2.07424163818e-5` | `6.67105892839e-7` |
429
+ | Bento Box | 1 | 8 | `5.48811255684e-6` | `105.211539803` | `8.64267349243e-5` | `5.32430305242e-6` | `1.99079513550e-5` | `6.69578823937e-7` |
430
+ | Cake Table | 0 | 7 | `1.26997889517e-5` | `97.9240699240` | `3.05831432343e-4` | `1.02028460064e-5` | `9.88245010376e-5` | `1.79971479210e-6` |
431
+ | Cake Table | 1 | 7 | `5.10831144860e-6` | `105.834452642` | `6.95586204529e-5` | `7.19363812652e-6` | `9.45329666138e-5` | `1.75942494680e-6` |
432
+ | Toy Box | 0 | 8 | `1.04906613643e-5` | `99.5839426331` | `1.33097171783e-4` | `7.98777295022e-6` | `3.38554382324e-5` | `9.17125873657e-7` |
433
+ | Toy Box | 1 | 8 | `1.17187871964e-5` | `98.6223466421` | `4.33802604675e-4` | `1.76268145823e-5` | `3.25739383698e-5` | `9.24976044259e-7` |
434
+
435
+ The independent reviewer measured these per-condition results. Values are reported as RGB RMSE / maximum per-step latent RMSE; each is below the fixed RGB and latent thresholds above.
436
+
437
+ | Scene | Seed | RGB RMSE | Maximum per-step latent RMSE |
438
+ | ---------- | ---: | -----------------: | ---------------------------: |
439
+ | Bento Box | 0 | `9.85132492492e-6` | `9.11307774482e-6` |
440
+ | Bento Box | 1 | `1.78408690247e-5` | `5.76063468136e-6` |
441
+ | Cake Table | 0 | `1.43160028885e-5` | `7.43683456343e-6` |
442
+ | Cake Table | 1 | `1.20999262219e-5` | `7.7582077503e-6` |
443
+ | Toy Box | 0 | `2.03771846015e-5` | `1.37336492006e-5` |
444
+ | Toy Box | 1 | `1.27287351725e-5` | `1.90641440735e-5` |
445
+
446
+ The Cake Table checkpoint-step control kept the notebook's per-layer inversion steps `[500, 1000, 1000, 1000, 100]` in layer order. Setting all five to `1000` changed the seed-0 RGB RMSE to `0.120`, showing that the selected checkpoint step for each layer affects the result.
447
+
448
+ ## Reproducibility
449
+
450
+ See [REPRODUCING.md](https://github.com/creative-graphic-design/design-generators/blob/main/models/collage-diffusion/REPRODUCING.md) for CPU test and notebook-comparison steps.
451
+
452
+ ## Environmental Impact
453
+
454
+ No model training is performed. CPU validation cost depends on the selected Colab memory profile, dependency installation, and comparison resolution.
455
+
456
+ ## Technical Specifications
457
+
458
+ ### Model Architecture and Objective
459
+
460
+ The pipeline uses Stable Diffusion image-to-image components with a `diffusers` attention processor. It composites layers in back-to-front order, derives visible alpha masks, adjusts cross-attention logits for layer tokens, and blends the initial latent into each denoising step according to the inverted per-layer noise map.
461
+
462
+ ### Compute Infrastructure
463
+
464
+ CPU is sufficient for small contract tests. Image generation uses the upstream Stable Diffusion checkpoint and is substantially slower on CPU than on a GPU.
465
+
466
+ #### Hardware
467
+
468
+ The requested implementation checks and image comparison run on a named Colab CPU session. No GPU is required for the planned comparison.
469
+
470
+ #### Software
471
+
472
+ Use Python 3.11 or newer, `diffusers`, `transformers`, PyTorch, Pillow, NumPy, and the repository's `laygen` workspace library. The pipeline uses the same workspace dependency configuration as other model packages.
473
+
474
+ ## License
475
+
476
+ Package code follows the repository's Apache-2.0 license. The upstream notebook and UI source repositories do not declare a repository license; their implementations are referenced but not copied or distributed. Stable Diffusion 2.1 base is subject to its upstream CreativeML Open RAIL++-M terms. The original authors and rights holders of the notebook-linked example images and embeddings, and the license applicable to each file, are unknown. The archive records the URL from which each file was retrieved and its SHA-256; these records identify retrieval locations, not rights holders or license grants.
477
+
478
+ ## Citation
479
+
480
+ ```bibtex
481
+ @article{sarukkai2023collagediffusion,
482
+ title = {Collage Diffusion},
483
+ author = {Sarukkai, Vishnu and Li, Linden and Ma, Arden and Ré, Christopher and Fatahalian, Kayvon},
484
+ journal = {arXiv preprint arXiv:2303.00262},
485
+ year = {2023},
486
+ eprint = {2303.00262},
487
+ archivePrefix = {arXiv},
488
+ primaryClass = {cs.CV},
489
+ url = "https://arxiv.org/abs/2303.00262",
490
+ }
491
+ ```
492
+
493
+ <!-- --8<-- [end:card] -->
pipeline.py ADDED
@@ -0,0 +1,807 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Diffusers pipeline and layer helpers for collage harmonization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Literal, Protocol
10
+
11
+ import numpy as np
12
+ import torch
13
+ import torch.nn.functional as F
14
+ from diffusers import StableDiffusionImg2ImgPipeline
15
+ from diffusers.models.attention_processor import Attention
16
+ from diffusers.pipelines.stable_diffusion.pipeline_output import (
17
+ StableDiffusionPipelineOutput,
18
+ )
19
+ from jaxtyping import Float
20
+ from laygen.common.randomness import randn, resolve_torch_generator
21
+ from PIL import Image, ImageFilter
22
+
23
+
24
+ class TokenizerProtocol(Protocol):
25
+ """The tokenizer operation needed to locate layer phrases in a prompt."""
26
+
27
+ def encode(self, text: str, *, add_special_tokens: bool) -> list[int]:
28
+ """Encode text into token ids."""
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class CollageLayer:
33
+ """One positioned RGBA layer and the controls associated with its prompt.
34
+
35
+ Args:
36
+ image: RGBA image for the layer.
37
+ prompt: Layer description that also appears in the scene prompt.
38
+ position: Pixel ``(x, y)`` position on the output canvas.
39
+ noise_level: Layer-specific amount of source preservation, in ``[0, 1]``.
40
+ positive_attention_weight: Attention-logit boost inside the visible mask.
41
+ negative_attention_weight: Attention-logit penalty outside the visible mask.
42
+ textual_inversion_token: Optional token inserted before ``prompt``.
43
+
44
+ Raises:
45
+ TypeError: If the image or position has the wrong type.
46
+ ValueError: If the image mode, prompt, or numeric control is invalid.
47
+ """
48
+
49
+ image: Image.Image
50
+ prompt: str
51
+ position: tuple[int, int] = (0, 0)
52
+ noise_level: float = 0.7
53
+ positive_attention_weight: float = 0.0
54
+ negative_attention_weight: float = 0.0
55
+ textual_inversion_token: str | None = None
56
+
57
+ def __post_init__(self) -> None:
58
+ """Validate layer input modes and scalar controls."""
59
+ if not isinstance(self.image, Image.Image):
60
+ raise TypeError("image must be a PIL Image")
61
+
62
+ if self.image.mode != "RGBA":
63
+ raise ValueError("image must use RGBA mode")
64
+
65
+ if not self.prompt.strip():
66
+ raise ValueError("prompt must be a non-empty layer description")
67
+
68
+ if len(self.position) != 2 or any(
69
+ not isinstance(value, int) or isinstance(value, bool)
70
+ for value in self.position
71
+ ):
72
+ raise TypeError("position must contain two integer pixel coordinates")
73
+
74
+ if not math.isfinite(self.noise_level) or not 0.0 <= self.noise_level <= 1.0:
75
+ raise ValueError("noise_level must be finite and between 0 and 1")
76
+
77
+ for name, weight in (
78
+ ("positive_attention_weight", self.positive_attention_weight),
79
+ ("negative_attention_weight", self.negative_attention_weight),
80
+ ):
81
+ if not math.isfinite(weight) or weight < 0:
82
+ raise ValueError(f"{name} must be finite and non-negative")
83
+
84
+ if (
85
+ self.textual_inversion_token is not None
86
+ and not self.textual_inversion_token.strip()
87
+ ):
88
+ raise ValueError("textual_inversion_token must be non-empty when provided")
89
+
90
+
91
+ def _placed_alpha(
92
+ layer: CollageLayer,
93
+ *,
94
+ width: int,
95
+ height: int,
96
+ ) -> Float[np.ndarray, "height width"]:
97
+ canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
98
+ # Using the RGBA image as its own paste mask multiplies partial alpha by itself.
99
+ canvas.paste(layer.image, layer.position, layer.image)
100
+
101
+ # Keep alpha arithmetic at float64 until conversion to the 8-bit noise map.
102
+ return np.asarray(canvas.getchannel("A"), dtype=np.float64) / 255.0
103
+
104
+
105
+ def compose_collage_layers(
106
+ layers: Sequence[CollageLayer],
107
+ *,
108
+ width: int = 512,
109
+ height: int = 512,
110
+ background_color: tuple[int, int, int] = (150, 150, 150),
111
+ ) -> tuple[Image.Image, list[Float[np.ndarray, "height width"]]]:
112
+ """Composite layers back-to-front and return their visible alpha masks.
113
+
114
+ Args:
115
+ layers: Layers ordered from back to front.
116
+ width: Output canvas width in pixels.
117
+ height: Output canvas height in pixels.
118
+ background_color: RGB color used outside all layers.
119
+
120
+ Returns:
121
+ The RGB collage and one visible alpha mask per input layer.
122
+
123
+ Raises:
124
+ ValueError: If there are no layers or the canvas is invalid.
125
+
126
+ Examples:
127
+ >>> layer = CollageLayer(Image.new("RGBA", (2, 2), (255, 0, 0, 255)), "red square")
128
+ >>> image, masks = compose_collage_layers([layer], width=2, height=2)
129
+ >>> image.getpixel((0, 0)), float(masks[0][0, 0])
130
+ ((255, 0, 0), 1.0)
131
+ """
132
+ if not layers:
133
+ raise ValueError("layers must contain at least one RGBA image")
134
+
135
+ if width < 1 or height < 1:
136
+ raise ValueError("canvas width and height must be positive")
137
+
138
+ if len(background_color) != 3 or any(
139
+ not 0 <= channel <= 255 for channel in background_color
140
+ ):
141
+ raise ValueError("background_color must contain three byte values")
142
+
143
+ composite = Image.new("RGB", (width, height), background_color)
144
+ for layer in layers:
145
+ # Use each RGBA layer's alpha as its paste mask while compositing.
146
+ composite.paste(layer.image, layer.position, layer.image)
147
+
148
+ visible_masks: list[Float[np.ndarray, "height width"]] = []
149
+ for index, layer in enumerate(layers):
150
+ layer_alpha = _placed_alpha(layer, width=width, height=height)
151
+
152
+ top_canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
153
+ for top_layer in layers[index + 1 :]:
154
+ top_canvas.paste(top_layer.image, top_layer.position, top_layer.image)
155
+
156
+ top_alpha = np.asarray(top_canvas.getchannel("A"), dtype=np.float64) / 255.0
157
+ visible_masks.append(layer_alpha * (1.0 - top_alpha))
158
+
159
+ return composite, visible_masks
160
+
161
+
162
+ def make_layer_noise_map(
163
+ visible_masks: Sequence[Float[np.ndarray, "height width"]],
164
+ layers: Sequence[CollageLayer],
165
+ *,
166
+ strength: float,
167
+ blur_radius: float = 10.0,
168
+ ) -> Float[np.ndarray, "height width"]:
169
+ """Blend per-layer noise controls over the global image-to-image strength.
170
+
171
+ Args:
172
+ visible_masks: Visible alpha masks in back-to-front layer order.
173
+ layers: Layers whose ``noise_level`` values drive the map.
174
+ strength: Global image-to-image strength used outside layer masks.
175
+ blur_radius: Gaussian blur radius for smooth layer boundaries.
176
+
177
+ Returns:
178
+ A grayscale float map with values in ``[0, 1]``.
179
+
180
+ Raises:
181
+ ValueError: If masks, layers, or controls are inconsistent.
182
+ """
183
+ if len(visible_masks) != len(layers) or not layers:
184
+ raise ValueError("visible_masks and layers must have the same non-zero length")
185
+
186
+ if not math.isfinite(strength) or not 0.0 < strength <= 1.0:
187
+ raise ValueError("strength must be finite and between 0 and 1")
188
+
189
+ if not math.isfinite(blur_radius) or blur_radius < 0:
190
+ raise ValueError("blur_radius must be finite and non-negative")
191
+
192
+ noise_map = np.full_like(visible_masks[0], int(strength * 255), dtype=np.uint8)
193
+ for mask, layer in zip(visible_masks, layers, strict=True):
194
+ if mask.shape != noise_map.shape:
195
+ raise ValueError("all visible masks must have the same shape")
196
+
197
+ if not np.isfinite(mask).all() or np.any(mask < 0.0) or np.any(mask > 1.0):
198
+ raise ValueError("visible masks must contain finite values between 0 and 1")
199
+
200
+ layer_values = Image.fromarray(
201
+ (mask * layer.noise_level * 255).astype(np.uint8)
202
+ )
203
+ layer_mask = Image.fromarray((mask * 255).astype(np.uint8))
204
+ collage_map = Image.fromarray(noise_map)
205
+ collage_map.paste(layer_values, (0, 0), layer_mask)
206
+ noise_map = np.asarray(collage_map, dtype=np.uint8)
207
+
208
+ grayscale = Image.fromarray(noise_map)
209
+ if blur_radius > 0:
210
+ grayscale = grayscale.filter(ImageFilter.GaussianBlur(blur_radius))
211
+
212
+ return np.asarray(grayscale, dtype=np.float32) / 255.0
213
+
214
+
215
+ def _find_token_span(
216
+ prompt_token_ids: Sequence[int],
217
+ layer_token_ids: Sequence[int],
218
+ ) -> tuple[int, ...]:
219
+ if not layer_token_ids:
220
+ raise ValueError("layer descriptions must produce at least one tokenizer token")
221
+
222
+ token_count = len(layer_token_ids)
223
+ for start in range(1, len(prompt_token_ids) - token_count + 1):
224
+ if tuple(prompt_token_ids[start : start + token_count]) == tuple(
225
+ layer_token_ids
226
+ ):
227
+ return tuple(range(start, start + token_count))
228
+
229
+ raise ValueError("each layer description must appear as a token span in the prompt")
230
+
231
+
232
+ def _prompt_token_spans(
233
+ tokenizer: TokenizerProtocol,
234
+ prompt: str,
235
+ layers: Sequence[CollageLayer],
236
+ ) -> tuple[tuple[int, ...], ...]:
237
+ prompt_ids = tokenizer.encode(prompt, add_special_tokens=True)
238
+ spans: list[tuple[int, ...]] = []
239
+ textual_inversion_enabled = any(
240
+ layer.textual_inversion_token is not None for layer in layers
241
+ )
242
+
243
+ for layer in layers:
244
+ try:
245
+ layer_ids = tokenizer.encode(f" {layer.prompt}", add_special_tokens=False)
246
+ span = _find_token_span(prompt_ids, layer_ids)
247
+ except ValueError:
248
+ layer_ids = tokenizer.encode(layer.prompt, add_special_tokens=False)
249
+ span = _find_token_span(prompt_ids, layer_ids)
250
+
251
+ if textual_inversion_enabled:
252
+ # Extend each span's start one token left to include a prepended TI token.
253
+ span = tuple(range(span[0] - 1, span[-1] + 1))
254
+
255
+ spans.append(span)
256
+
257
+ return tuple(spans)
258
+
259
+
260
+ def _inject_textual_inversion_tokens(
261
+ prompt: str,
262
+ layers: Sequence[CollageLayer],
263
+ embedding_sources: Mapping[str, str | Path] | None,
264
+ ) -> str:
265
+ requested_tokens = {
266
+ layer.textual_inversion_token
267
+ for layer in layers
268
+ if layer.textual_inversion_token is not None
269
+ }
270
+ supplied_tokens = set() if embedding_sources is None else set(embedding_sources)
271
+ unused_tokens = supplied_tokens - requested_tokens
272
+ if unused_tokens:
273
+ raise ValueError(
274
+ f"textual inversion embeddings have no matching layer: {sorted(unused_tokens)}"
275
+ )
276
+
277
+ for layer in layers:
278
+ token = layer.textual_inversion_token
279
+ if token is None:
280
+ continue
281
+
282
+ if prompt.count(layer.prompt) != 1:
283
+ raise ValueError(
284
+ "each layer description must occur exactly once in the scene prompt"
285
+ )
286
+
287
+ prompt = prompt.replace(layer.prompt, f"{token} {layer.prompt}", 1)
288
+
289
+ return prompt
290
+
291
+
292
+ def _resize_mask_to_query(
293
+ mask: Float[np.ndarray, "height width"],
294
+ *,
295
+ size: tuple[int, int],
296
+ device: torch.device,
297
+ ) -> Float[torch.Tensor, "query"]:
298
+ """Resize a visible mask with the reference tensor-sampling semantics."""
299
+ mask_tensor = torch.as_tensor(mask, device=device, dtype=torch.float64)
300
+ batched_mask = mask_tensor.unsqueeze(0).unsqueeze(0)
301
+ resized_mask = F.interpolate(
302
+ batched_mask,
303
+ size=size,
304
+ mode="bilinear",
305
+ align_corners=False,
306
+ antialias=False,
307
+ )
308
+ resized_mask = resized_mask.squeeze(0).squeeze(0)
309
+ query_length = size[0] * size[1]
310
+
311
+ return resized_mask.reshape(query_length)
312
+
313
+
314
+ class _CollageCrossAttentionProcessor:
315
+ """Apply visible-layer logit guidance through Diffusers' processor API."""
316
+
317
+ def __init__(
318
+ self,
319
+ *,
320
+ visible_masks: Sequence[Float[np.ndarray, "height width"]],
321
+ token_spans: Sequence[Sequence[int]],
322
+ layers: Sequence[CollageLayer],
323
+ output_size: tuple[int, int],
324
+ batch_size: int,
325
+ classifier_free_guidance: bool,
326
+ ) -> None:
327
+ self.visible_masks = tuple(visible_masks)
328
+ self.token_spans = tuple(tuple(span) for span in token_spans)
329
+ self.layers = tuple(layers)
330
+ self.output_size = output_size
331
+ self.batch_size = batch_size
332
+ self.classifier_free_guidance = classifier_free_guidance
333
+ self.sigma = 1.0
334
+
335
+ def __call__(
336
+ self,
337
+ attn: Attention,
338
+ hidden_states: Float[torch.Tensor, "batch sequence channels"],
339
+ encoder_hidden_states: Float[torch.Tensor, "batch tokens channels"]
340
+ | None = None,
341
+ attention_mask: Float[torch.Tensor, "..."] | None = None,
342
+ temb: Float[torch.Tensor, "..."] | None = None,
343
+ scale: float | None = None,
344
+ ) -> Float[torch.Tensor, "batch sequence channels"]:
345
+ # Add per-layer score bias before softmax; CFG leaves unconditional rows unchanged.
346
+ del scale
347
+ residual = hidden_states
348
+ spatial_dimensions: tuple[int, int, int] | None = None
349
+ if attn.spatial_norm is not None:
350
+ hidden_states = attn.spatial_norm(hidden_states, temb)
351
+
352
+ input_ndim = hidden_states.ndim
353
+ if input_ndim == 4:
354
+ batch_size, channel, height, width = hidden_states.shape
355
+ spatial_dimensions = (channel, height, width)
356
+ hidden_states = hidden_states.view(batch_size, channel, height * width)
357
+ hidden_states = hidden_states.transpose(1, 2)
358
+
359
+ batch_size, sequence_length, _ = (
360
+ hidden_states.shape
361
+ if encoder_hidden_states is None
362
+ else encoder_hidden_states.shape
363
+ )
364
+ if attention_mask is not None:
365
+ attention_mask = attn.prepare_attention_mask(
366
+ attention_mask, sequence_length, batch_size
367
+ )
368
+ attention_mask = attention_mask.view(
369
+ batch_size, attn.heads, -1, attention_mask.shape[-1]
370
+ )
371
+
372
+ if attn.group_norm is not None:
373
+ hidden_states = hidden_states.transpose(1, 2)
374
+ hidden_states = attn.group_norm(hidden_states)
375
+ hidden_states = hidden_states.transpose(1, 2)
376
+
377
+ query = attn.to_q(hidden_states)
378
+ if encoder_hidden_states is None:
379
+ encoder_hidden_states = hidden_states
380
+ elif attn.norm_cross:
381
+ encoder_hidden_states = attn.norm_encoder_hidden_states(
382
+ encoder_hidden_states
383
+ )
384
+
385
+ to_k = attn.to_k
386
+ to_v = attn.to_v
387
+ to_out = attn.to_out
388
+ if to_k is None or to_v is None or to_out is None:
389
+ raise ValueError(
390
+ "cross-attention requires key, value, and output projections"
391
+ )
392
+
393
+ key = to_k(encoder_hidden_states)
394
+ value = to_v(encoder_hidden_states)
395
+ head_dim = key.shape[-1] // attn.heads
396
+ query = query.view(batch_size, -1, attn.heads, head_dim)
397
+ query = query.transpose(1, 2)
398
+ key = key.view(batch_size, -1, attn.heads, head_dim)
399
+ key = key.transpose(1, 2)
400
+ value = value.view(batch_size, -1, attn.heads, head_dim)
401
+ value = value.transpose(1, 2)
402
+
403
+ if attn.norm_q is not None:
404
+ query = attn.norm_q(query)
405
+
406
+ if attn.norm_k is not None:
407
+ key = attn.norm_k(key)
408
+
409
+ score_query = query.float() if attn.upcast_attention else query
410
+ score_key = key.float() if attn.upcast_attention else key
411
+ scores = torch.matmul(score_query, score_key.transpose(-1, -2)) * attn.scale
412
+ if attention_mask is not None:
413
+ scores = scores + attention_mask
414
+
415
+ self._apply_layer_guidance(scores)
416
+ softmax_scores = scores.float() if attn.upcast_softmax else scores
417
+ attention_probs = softmax_scores.softmax(dim=-1).to(value.dtype)
418
+ output = torch.matmul(attention_probs, value)
419
+ output = output.transpose(1, 2)
420
+ output = output.reshape(batch_size, -1, attn.heads * head_dim)
421
+ output = output.to(query.dtype)
422
+ output = to_out[0](output)
423
+ output = to_out[1](output)
424
+
425
+ if spatial_dimensions is not None:
426
+ channel, height, width = spatial_dimensions
427
+ output = output.transpose(-1, -2)
428
+ output = output.reshape(batch_size, channel, height, width)
429
+
430
+ if attn.residual_connection:
431
+ output = output + residual
432
+
433
+ return output / attn.rescale_output_factor
434
+
435
+ def _apply_layer_guidance(
436
+ self, scores: Float[torch.Tensor, "batch heads query tokens"]
437
+ ) -> None:
438
+ query_length = scores.shape[-2]
439
+ output_height, output_width = self.output_size
440
+ target_height = max(
441
+ 1, int(round(math.sqrt(query_length * output_height / output_width)))
442
+ )
443
+ while query_length % target_height:
444
+ target_height -= 1
445
+
446
+ target_width = query_length // target_height
447
+ latent_masks = [
448
+ _resize_mask_to_query(
449
+ mask,
450
+ size=(target_height, target_width),
451
+ device=scores.device,
452
+ )
453
+ for mask in self.visible_masks
454
+ ]
455
+ # CFG batches unconditional rows before conditional rows; guide only the latter.
456
+ cfg_offset = self.batch_size if self.classifier_free_guidance else 0
457
+ sigma_scale = math.log(math.log(max(self.sigma, 0.0) + 1.01) + 1.0)
458
+
459
+ for batch_index in range(self.batch_size):
460
+ conditional_index = batch_index + cfg_offset
461
+ for mask, span, layer in zip(
462
+ latent_masks, self.token_spans, self.layers, strict=True
463
+ ):
464
+ for token_index in span:
465
+ if token_index >= scores.shape[-1]:
466
+ raise ValueError(
467
+ "layer token span exceeds the text encoder sequence length"
468
+ )
469
+
470
+ token_scores = scores[conditional_index, :, :, token_index]
471
+ max_score = token_scores.max()
472
+ visible_mask = mask.unsqueeze(0)
473
+ non_visible_mask = 1.0 - visible_mask
474
+ positive_bias = visible_mask * layer.positive_attention_weight
475
+ negative_bias = non_visible_mask * layer.negative_attention_weight
476
+ bias = (positive_bias - negative_bias) * sigma_scale * max_score
477
+ scores[conditional_index, :, :, token_index] += bias
478
+
479
+
480
+ class CollageDiffusionPipeline(StableDiffusionImg2ImgPipeline):
481
+ """Harmonize positioned image layers with layer-aware diffusion controls.
482
+
483
+ The pipeline loads the standard Stable Diffusion image-to-image components and
484
+ replaces only the sampling orchestration needed for visible-layer attention
485
+ and noise guidance.
486
+
487
+ Examples:
488
+ >>> from PIL import Image
489
+ >>> from collage_diffusion import CollageLayer
490
+ >>> layer = CollageLayer(Image.new("RGBA", (16, 16), (220, 80, 30, 255)), "orange toy")
491
+ >>> layer.prompt
492
+ 'orange toy'
493
+ """
494
+
495
+ @torch.no_grad()
496
+ def __call__(
497
+ self,
498
+ prompt: str,
499
+ layers: Sequence[CollageLayer],
500
+ *,
501
+ negative_prompt: str | None = None,
502
+ strength: float = 0.7,
503
+ num_inference_steps: int = 50,
504
+ guidance_scale: float = 7.5,
505
+ num_images_per_prompt: int = 1,
506
+ generator: torch.Generator | None = None,
507
+ seed: int | None = None,
508
+ textual_inversion_embeddings: Mapping[str, str | Path] | None = None,
509
+ height: int = 512,
510
+ width: int = 512,
511
+ noise_blur: float = 10.0,
512
+ eta: float = 0.0,
513
+ output_type: Literal["pil", "np"] = "pil",
514
+ return_dict: bool = True,
515
+ step_callback: Callable[
516
+ [int, float, Float[torch.Tensor, "batch channels height width"]], None
517
+ ]
518
+ | None = None,
519
+ ) -> (
520
+ StableDiffusionPipelineOutput
521
+ | tuple[
522
+ list[Image.Image] | Float[np.ndarray, "batch height width channels"],
523
+ list[bool] | None,
524
+ ]
525
+ ):
526
+ """Generate a harmonized collage from an ordered set of RGBA layers.
527
+
528
+ Args:
529
+ prompt: Global scene description containing every layer description.
530
+ layers: Back-to-front RGBA layers with descriptions and controls.
531
+ negative_prompt: Optional text to suppress during classifier-free guidance.
532
+ strength: Global image-to-image strength in ``(0, 1]``.
533
+ num_inference_steps: Number of scheduler steps before strength adjustment.
534
+ guidance_scale: Classifier-free guidance scale.
535
+ num_images_per_prompt: Number of images to generate for the prompt.
536
+ generator: Explicit random generator; takes precedence over ``seed``.
537
+ seed: Optional local seed used when ``generator`` is absent.
538
+ textual_inversion_embeddings: Map from layer token to caller-provided weights.
539
+ height: Output canvas height; must be divisible by the VAE scale factor.
540
+ width: Output canvas width; must be divisible by the VAE scale factor.
541
+ noise_blur: Gaussian blur radius for the blended per-layer noise map.
542
+ eta: Optional scheduler noise parameter used by DDIM-compatible schedulers.
543
+ output_type: ``"pil"`` or ``"np"`` image output format.
544
+ return_dict: Whether to return ``StableDiffusionPipelineOutput``.
545
+ step_callback: Optional callback receiving step index, timestep, and latents.
546
+
547
+ Returns:
548
+ A Diffusers image output or its standard tuple representation.
549
+
550
+ Raises:
551
+ TypeError: If a layer sequence contains an unsupported value.
552
+ ValueError: If the scheduler does not expose sigmas or generation inputs are invalid.
553
+ """
554
+ # Add layer-aware attention guidance and restore source regions during denoising.
555
+ # 1. Validate scheduler, prompts, layers, and generation settings.
556
+ if getattr(self.scheduler, "sigmas", None) is None:
557
+ raise ValueError(
558
+ "scheduler must expose `sigmas` for cross-attention guidance; "
559
+ "use EulerAncestralDiscreteScheduler"
560
+ )
561
+
562
+ if not prompt.strip():
563
+ raise ValueError("prompt must be non-empty")
564
+
565
+ if not layers:
566
+ raise ValueError("layers must contain at least one layer")
567
+
568
+ if any(not isinstance(layer, CollageLayer) for layer in layers):
569
+ raise TypeError("layers must contain CollageLayer values")
570
+
571
+ if not math.isfinite(strength) or not 0.0 < strength <= 1.0:
572
+ raise ValueError("strength must be finite and between 0 and 1")
573
+
574
+ if num_inference_steps < 1 or num_images_per_prompt < 1:
575
+ raise ValueError(
576
+ "num_inference_steps and num_images_per_prompt must be positive"
577
+ )
578
+
579
+ if not math.isfinite(guidance_scale) or guidance_scale < 0.0:
580
+ raise ValueError("guidance_scale must be finite and non-negative")
581
+
582
+ if height < 1 or width < 1:
583
+ raise ValueError("height and width must be positive")
584
+
585
+ if output_type not in {"pil", "np"}:
586
+ raise ValueError("output_type must be 'pil' or 'np'")
587
+
588
+ device = self._execution_device
589
+ vae_scale_factor = self.vae_scale_factor
590
+ if height % vae_scale_factor or width % vae_scale_factor:
591
+ raise ValueError(
592
+ "height and width must be divisible by the VAE scale factor"
593
+ )
594
+
595
+ # 2. Prepare layer prompts, collage image, visible masks, and noise map.
596
+ generator = resolve_torch_generator(generator=generator, seed=seed)
597
+ prompt = _inject_textual_inversion_tokens(
598
+ prompt, layers, textual_inversion_embeddings
599
+ )
600
+ if textual_inversion_embeddings is not None:
601
+ for token, embedding_source in textual_inversion_embeddings.items():
602
+ self.load_textual_inversion(embedding_source, token=token)
603
+
604
+ token_spans = _prompt_token_spans(self.tokenizer, prompt, layers)
605
+ collage, visible_masks = compose_collage_layers(
606
+ layers, width=width, height=height
607
+ )
608
+ noise_map = make_layer_noise_map(
609
+ visible_masks, layers, strength=strength, blur_radius=noise_blur
610
+ )
611
+ image = self.image_processor.preprocess(collage, height=height, width=width)
612
+ image = image.to(device=device, dtype=self.unet.dtype)
613
+
614
+ # 3. Encode the prompt and batch both guidance branches when guidance_scale > 1.
615
+ do_classifier_free_guidance = guidance_scale > 1.0
616
+ prompt_embeds, negative_prompt_embeds = self.encode_prompt(
617
+ prompt,
618
+ device,
619
+ num_images_per_prompt,
620
+ do_classifier_free_guidance,
621
+ negative_prompt=negative_prompt,
622
+ )
623
+ if do_classifier_free_guidance:
624
+ if negative_prompt_embeds is None:
625
+ raise ValueError(
626
+ "classifier-free guidance requires negative prompt embeddings"
627
+ )
628
+
629
+ # Concatenate both branches so the U-Net handles them in one call.
630
+ prompt_embeds = torch.cat([negative_prompt_embeds, prompt_embeds])
631
+
632
+ # 4. Encode the collage and prepare its initial latent and noise.
633
+ encoded = self.vae.encode(image)
634
+ init_latents = encoded.latent_dist.sample(generator=generator)
635
+ init_latents = init_latents * self.vae.config.scaling_factor
636
+ init_latents = init_latents.repeat(num_images_per_prompt, 1, 1, 1)
637
+ initial_noise = randn(
638
+ init_latents.shape,
639
+ generator=generator,
640
+ device=device,
641
+ dtype=init_latents.dtype,
642
+ )
643
+
644
+ # 5. Set img2img timesteps and build the latent-resolution preservation mask.
645
+ self.scheduler.set_timesteps(num_inference_steps, device=device)
646
+ scheduler_offset = int(self.scheduler.config.get("steps_offset", 0))
647
+ init_timestep = min(
648
+ int(num_inference_steps * strength) + scheduler_offset,
649
+ num_inference_steps,
650
+ )
651
+ timestep_start = max(
652
+ num_inference_steps - init_timestep + scheduler_offset,
653
+ 0,
654
+ )
655
+ timesteps = self.scheduler.timesteps[timestep_start:]
656
+ effective_steps = num_inference_steps - timestep_start
657
+ if len(timesteps) == 0:
658
+ raise ValueError(
659
+ "strength and num_inference_steps produced no denoising steps"
660
+ )
661
+
662
+ latent_timestep = timesteps[:1].repeat(num_images_per_prompt)
663
+ latents = self.scheduler.add_noise(init_latents, initial_noise, latent_timestep)
664
+ noise_mask_pixels = np.rint(noise_map * 255.0).astype(np.uint8)
665
+ # Quantize the source strength map before resizing it to the latent grid.
666
+ noise_mask_image = Image.fromarray(noise_mask_pixels)
667
+ noise_mask_image = noise_mask_image.resize(
668
+ (latents.shape[-1], latents.shape[-2]),
669
+ resample=Image.Resampling.NEAREST,
670
+ )
671
+ noise_mask_values = np.asarray(noise_mask_image, dtype=np.float32)
672
+ noise_mask_values = noise_mask_values.copy()
673
+ normalized_noise_mask = noise_mask_values / 255.0
674
+ preservation_mask = 1.0 - normalized_noise_mask
675
+ preservation_mask_tensor = torch.as_tensor(
676
+ preservation_mask, device=device, dtype=latents.dtype
677
+ )
678
+ batched_preservation_mask = preservation_mask_tensor.unsqueeze(0).unsqueeze(0)
679
+ noise_mask = batched_preservation_mask.expand(
680
+ num_images_per_prompt, 1, *latents.shape[-2:]
681
+ )
682
+
683
+ # 6. Install layer-aware attention processors and prepare scheduler arguments.
684
+ previous_processors = dict(self.unet.attn_processors)
685
+ controlled_processors = {
686
+ name: _CollageCrossAttentionProcessor(
687
+ visible_masks=visible_masks,
688
+ token_spans=token_spans,
689
+ layers=layers,
690
+ output_size=(height, width),
691
+ batch_size=num_images_per_prompt,
692
+ classifier_free_guidance=do_classifier_free_guidance,
693
+ )
694
+ if name.endswith(".attn2.processor")
695
+ else processor
696
+ for name, processor in previous_processors.items()
697
+ }
698
+ controlled_attention_processors = tuple(
699
+ processor
700
+ for processor in controlled_processors.values()
701
+ if isinstance(processor, _CollageCrossAttentionProcessor)
702
+ )
703
+ attention_control_enabled = any(
704
+ layer.positive_attention_weight or layer.negative_attention_weight
705
+ for layer in layers
706
+ )
707
+ if attention_control_enabled and not controlled_attention_processors:
708
+ raise ValueError("the U-Net exposes no cross-attention processors")
709
+
710
+ if attention_control_enabled:
711
+ self.unet.set_attn_processor(controlled_processors)
712
+
713
+ extra_step_kwargs = self.prepare_extra_step_kwargs(generator, eta)
714
+ self._num_timesteps = len(timesteps)
715
+ try:
716
+ # 7. Denoise, applying attention guidance and layer-specific preservation.
717
+ with self.progress_bar(total=effective_steps) as progress_bar:
718
+ for step_index, timestep in enumerate(timesteps):
719
+ if attention_control_enabled:
720
+ sigma = float(self.scheduler.sigmas[step_index].item())
721
+ for processor in controlled_attention_processors:
722
+ processor.sigma = sigma
723
+
724
+ latent_model_input = (
725
+ torch.cat([latents] * 2)
726
+ if do_classifier_free_guidance
727
+ else latents
728
+ )
729
+ latent_model_input = self.scheduler.scale_model_input(
730
+ latent_model_input, timestep
731
+ )
732
+ noise_prediction = self.unet(
733
+ latent_model_input,
734
+ timestep,
735
+ encoder_hidden_states=prompt_embeds,
736
+ ).sample
737
+ if do_classifier_free_guidance:
738
+ # Standard CFG: unconditional + scale * (conditional - unconditional).
739
+ unconditional, conditional = noise_prediction.chunk(2)
740
+ noise_prediction = unconditional + guidance_scale * (
741
+ conditional - unconditional
742
+ )
743
+
744
+ # Advance x_t to x_(t-1); the mask below restores source regions.
745
+ latents = self.scheduler.step(
746
+ noise_prediction,
747
+ timestep,
748
+ latents,
749
+ **extra_step_kwargs,
750
+ ).prev_sample
751
+ step_noise = randn(
752
+ latents.shape,
753
+ generator=generator,
754
+ device=device,
755
+ dtype=latents.dtype,
756
+ )
757
+ timestep_batch = timestep.expand(latents.shape[0])
758
+ preserved_latents = self.scheduler.add_noise(
759
+ init_latents, step_noise, timestep_batch
760
+ )
761
+ timestep_value = float(timestep.item())
762
+ # Apply the timestep-dependent preservation threshold and spread it to neighbors.
763
+ mask_t = (noise_mask > 1.0 - timestep_value / 1000.0).to(
764
+ latents.dtype
765
+ )
766
+ dilation = int(4.0 * timestep_value / 1000.0)
767
+ if dilation > 0:
768
+ mask_t = F.max_pool2d(
769
+ mask_t,
770
+ kernel_size=dilation * 2 + 1,
771
+ stride=1,
772
+ padding=dilation,
773
+ )
774
+
775
+ latents = preserved_latents * mask_t + latents * (1.0 - mask_t)
776
+ if step_callback is not None:
777
+ step_callback(step_index, timestep_value, latents)
778
+
779
+ progress_bar.update()
780
+ finally:
781
+ if attention_control_enabled:
782
+ self.unet.set_attn_processor(previous_processors)
783
+
784
+ # 8. Decode the final latent and return the standard Diffusers output.
785
+ decoded = self.vae.decode(
786
+ latents / self.vae.config.scaling_factor, return_dict=False
787
+ )[0]
788
+ decoded, has_nsfw_concept = self.run_safety_checker(
789
+ decoded, device, prompt_embeds.dtype
790
+ )
791
+ do_denormalize = (
792
+ [not flag for flag in has_nsfw_concept]
793
+ if has_nsfw_concept is not None
794
+ else None
795
+ )
796
+ images = self.image_processor.postprocess(
797
+ decoded, output_type=output_type, do_denormalize=do_denormalize
798
+ )
799
+ self.maybe_free_model_hooks()
800
+
801
+ if not return_dict:
802
+ return images, has_nsfw_concept
803
+
804
+ return StableDiffusionPipelineOutput(
805
+ images=images,
806
+ nsfw_content_detected=has_nsfw_concept,
807
+ )