Instructions to use creative-graphic-design/collage-diffusion with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use creative-graphic-design/collage-diffusion with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline from diffusers.utils import load_image # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("creative-graphic-design/collage-diffusion", dtype=torch.bfloat16, device_map="cuda") prompt = "Turn this cat into a dog" input_image = load_image("https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png") image = pipe(image=input_image, prompt=prompt).images[0] - Notebooks
- Google Colab
- Kaggle
Publish Collage Diffusion pipeline at 9fff847022ee
Browse files- README.md +490 -4
- pipeline.py +807 -0
README.md
CHANGED
|
@@ -1,7 +1,493 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
[](https://arxiv.org/abs/2303.00262)
|
| 19 |
+

|
| 20 |
+

|
| 21 |
+

|
| 22 |
+

|
| 23 |
+

|
| 24 |
+

|
| 25 |
+

|
| 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 |
+

|
| 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 |
+

|
| 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 |
+

|
| 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 |
+
)
|