Image-to-Image
Diffusers
Safetensors
Core ML
StableDiffusionInpaintPipeline
clover-image
inpainting
stable-diffusion
Instructions to use neonforestmist/Clover-Image-Tiny-Inpaint with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use neonforestmist/Clover-Image-Tiny-Inpaint 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("neonforestmist/Clover-Image-Tiny-Inpaint", 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
Document and package context-aware inpainting v2
Browse filesAdds selected training provenance, release metrics, real inpainting examples, runtime recommendations, and current training/evaluation sources.
- .gitattributes +6 -0
- README.md +94 -56
- examples/bicycle-mask.png +0 -0
- examples/{result-cat.png → bicycle-result.png} +2 -2
- examples/{source-greenhouse.png → bicycle-source.png} +2 -2
- examples/cat-mask.png +0 -0
- examples/cat-result.png +3 -0
- examples/cat-source.png +3 -0
- examples/kettle-mask.png +0 -0
- examples/kettle-result.png +3 -0
- examples/kettle-source.png +3 -0
- examples/mask-doorway.png +0 -0
- inpainting-config.json +59 -2
- inpainting/README.md +58 -14
- inpainting/__init__.py +1 -1
- inpainting/__pycache__/__init__.cpython-311.pyc +0 -0
- inpainting/__pycache__/masks.cpython-311.pyc +0 -0
- inpainting/__pycache__/model.cpython-311.pyc +0 -0
- inpainting/__pycache__/train.cpython-311.pyc +0 -0
- inpainting/config.json +43 -9
- inpainting/evaluate.py +247 -0
- inpainting/evaluate_semantic.py +494 -0
- inpainting/masks.py +182 -38
- inpainting/objective.py +71 -0
- inpainting/test_v2.py +79 -0
- inpainting/train.py +315 -105
- modal_inpaint.py +125 -26
- modal_inpaint_eval.py +93 -0
- modal_inpaint_semantic_eval.py +122 -0
- modal_inpaint_snapshot.py +93 -0
- training-complete.json +6 -0
- training-summary.json +29 -10
- training/README-INPAINTING.md +33 -14
- unet/config.json +1 -1
.gitattributes
CHANGED
|
@@ -35,3 +35,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
examples/result-cat.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
examples/source-greenhouse.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
examples/result-cat.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
examples/source-greenhouse.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
examples/bicycle-result.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
examples/bicycle-source.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
examples/cat-result.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
examples/cat-source.png filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
examples/kettle-result.png filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
examples/kettle-source.png filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -12,102 +12,140 @@ tags:
|
|
| 12 |
|
| 13 |
# Clover Image Tiny Inpaint 🍀
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
are preserved.
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
-
The model uses
|
| 21 |
|
| 22 |
```text
|
| 23 |
[noisy latent (4), mask (1), masked-image latent (4)]
|
| 24 |
```
|
| 25 |
|
| 26 |
-
The
|
| 27 |
-
compatible with Clover Image Tiny.
|
| 28 |
-
|
| 29 |
|
| 30 |
## Diffusers example
|
| 31 |
|
| 32 |
```python
|
|
|
|
| 33 |
from diffusers import AutoPipelineForInpainting, DPMSolverMultistepScheduler
|
| 34 |
from diffusers.utils import load_image
|
| 35 |
|
| 36 |
pipe = AutoPipelineForInpainting.from_pretrained(
|
| 37 |
"neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 38 |
-
torch_dtype=
|
| 39 |
-
)
|
| 40 |
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
mask_image=load_image("mask.png"),
|
| 45 |
num_inference_steps=20,
|
| 46 |
-
|
|
|
|
| 47 |
).images[0]
|
| 48 |
-
|
| 49 |
```
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
- `add a red enamel kettle on the masked countertop`
|
| 57 |
|
| 58 |
-
##
|
| 59 |
|
| 60 |
-
|
| 61 |
-
prompt is `a realistic orange cat sitting in the doorway, detailed photography`.
|
| 62 |
-
Only the white doorway mask is replaced.
|
| 63 |
|
| 64 |
| Source | White mask | Result |
|
| 65 |
|:---:|:---:|:---:|
|
| 66 |
-
| 
|
| 91 |
-
- [`training/README-INPAINTING.md`](https://huggingface.co/neonforestmist/Clover-Image-Tiny/blob/main/training/README-INPAINTING.md)
|
| 92 |
|
| 93 |
-
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
## Training provenance
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
```bibtex
|
| 113 |
@software{lozadaperez2026cloverimagetinyinpaint,
|
|
|
|
| 12 |
|
| 13 |
# Clover Image Tiny Inpaint 🍀
|
| 14 |
|
| 15 |
+
Clover Image Tiny Inpaint is a compact, context-aware inpainting model for the
|
| 16 |
+
SD 1.4-class 512×512 architecture. Paint the region to replace in white; black
|
| 17 |
+
pixels are preserved. This v2 checkpoint was selected from bounded
|
| 18 |
+
teacher-distillation sweeps for semantic prompt following, reconstruction, and
|
| 19 |
+
clean mask-edge blending.
|
| 20 |
|
| 21 |
+
The model uses the standard nine-channel inpainting contract:
|
| 22 |
|
| 23 |
```text
|
| 24 |
[noisy latent (4), mask (1), masked-image latent (4)]
|
| 25 |
```
|
| 26 |
|
| 27 |
+
The text encoder, VAE, scheduler, safety checker, and tokenizer remain
|
| 28 |
+
compatible with Clover Image Tiny. A VAE encoder is additionally required to
|
| 29 |
+
prepare the masked-image latent.
|
| 30 |
|
| 31 |
## Diffusers example
|
| 32 |
|
| 33 |
```python
|
| 34 |
+
import torch
|
| 35 |
from diffusers import AutoPipelineForInpainting, DPMSolverMultistepScheduler
|
| 36 |
from diffusers.utils import load_image
|
| 37 |
|
| 38 |
pipe = AutoPipelineForInpainting.from_pretrained(
|
| 39 |
"neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 40 |
+
torch_dtype=torch.float16,
|
| 41 |
+
).to("cuda")
|
| 42 |
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
|
| 43 |
+
|
| 44 |
+
result = pipe(
|
| 45 |
+
prompt="a glossy red enamel kettle resting naturally on the countertop",
|
| 46 |
+
negative_prompt="blurry, distorted, low detail",
|
| 47 |
+
image=load_image("source.png"),
|
| 48 |
mask_image=load_image("mask.png"),
|
| 49 |
num_inference_steps=20,
|
| 50 |
+
guidance_scale=6.0,
|
| 51 |
+
padding_mask_crop=96,
|
| 52 |
).images[0]
|
| 53 |
+
result.save("clover-inpaint.png")
|
| 54 |
```
|
| 55 |
|
| 56 |
+
Recommended interactive defaults are DPM-Solver++, 20 steps, CFG 6.0, and
|
| 57 |
+
`padding_mask_crop=96`. Keep runs at or below 50 steps. For production UI,
|
| 58 |
+
composite the generated image through the exact binary mask so that every
|
| 59 |
+
unmasked source pixel remains unchanged.
|
| 60 |
+
|
| 61 |
+
## Inpainting examples
|
| 62 |
|
| 63 |
+
All examples below use the selected v2 checkpoint, CFG 6.0, 30 evaluation
|
| 64 |
+
steps, and a 96-pixel context crop. White is the region regenerated by Clover.
|
|
|
|
| 65 |
|
| 66 |
+
### Context-aware object insertion
|
| 67 |
|
| 68 |
+
Prompt: `a tabby cat sitting naturally on the wooden park bench`
|
|
|
|
|
|
|
| 69 |
|
| 70 |
| Source | White mask | Result |
|
| 71 |
|:---:|:---:|:---:|
|
| 72 |
+
|  |  |  |
|
| 73 |
|
| 74 |
+
Prompt: `a glossy red enamel kettle resting naturally on the countertop`
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
+
| Source | White mask | Result |
|
| 77 |
+
|:---:|:---:|:---:|
|
| 78 |
+
|  |  |  |
|
|
|
|
| 79 |
|
| 80 |
+
### Irregular mask replacement
|
| 81 |
|
| 82 |
+
Prompt: `a bright red bicycle standing naturally on the city street`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
+
| Source | White mask | Result |
|
| 85 |
+
|:---:|:---:|:---:|
|
| 86 |
+
|  |  |  |
|
| 87 |
|
| 88 |
+
## Evaluation
|
|
|
|
|
|
|
| 89 |
|
| 90 |
+
The release gate used a deterministic 12-image held-out reconstruction set and
|
| 91 |
+
six context-rich semantic edits with ellipse, rounded-rectangle, polygon, and
|
| 92 |
+
brush masks.
|
| 93 |
|
| 94 |
+
| Metric | Previous release | v2 |
|
| 95 |
+
|---|---:|---:|
|
| 96 |
+
| Held-out masked-region MAE (lower is better) | 0.3120 | **0.2517** |
|
| 97 |
+
| Mean object-text CLIP similarity | 0.2726 | **0.2890** |
|
| 98 |
+
| Mean CLIP image similarity to the SD inpainting teacher | 0.7651 | **0.8183** |
|
| 99 |
+
| Black-collapse outputs | 0/6 | **0/6** |
|
| 100 |
+
| Changed pixels outside the mask | 0 | **0** |
|
| 101 |
+
|
| 102 |
+
Masked reconstruction MAE improved by 19.3%. Metrics are useful regression
|
| 103 |
+
signals, not guarantees of photorealism; the visual contact sheet and per-case
|
| 104 |
+
outputs were also reviewed before selection.
|
| 105 |
|
| 106 |
## Training provenance
|
| 107 |
|
| 108 |
+
- Warm start: `neonforestmist/Clover-Image-Tiny-Inpaint` at revision
|
| 109 |
+
`1b6f8ae3db51900520369d5522c7dc7c2a97e21e`
|
| 110 |
+
- Teacher: `stable-diffusion-v1-5/stable-diffusion-inpainting` at revision
|
| 111 |
+
`8a4288a76071f7280aedbdb3253bdb9e9d5d84bb`
|
| 112 |
+
- Dataset: `prithivMLmods/Caption3o-Opt` at revision
|
| 113 |
+
`17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b`
|
| 114 |
+
- Optimizer steps: 500, with cosine decay after warmup
|
| 115 |
+
- Objective: teacher noise prediction plus ground-truth denoising, Min-SNR
|
| 116 |
+
weighting, and extra masked-region/boundary weighting
|
| 117 |
+
- Masks: brush, multi-brush, rectangle, ellipse, polygon, multi-region, and
|
| 118 |
+
outpainting distributions
|
| 119 |
+
- Caption dropout: 0.1
|
| 120 |
+
- Training platform: Modal A10 in the `guccichungus69` workspace
|
| 121 |
+
|
| 122 |
+
The longer run was rejected after its held-out and semantic gates regressed;
|
| 123 |
+
the published checkpoint is the best-performing bounded sweep, not simply the
|
| 124 |
+
last checkpoint.
|
| 125 |
+
|
| 126 |
+
## Core ML
|
| 127 |
+
|
| 128 |
+
The companion SD 1.4-class Core ML resources are published at
|
| 129 |
+
[`neonforestmist/Clover-Image-Tiny-Inpaint-CoreML`](https://huggingface.co/neonforestmist/Clover-Image-Tiny-Inpaint-CoreML).
|
| 130 |
+
The batch-one U-Net accepts `[1, 9, 64, 64]`; classifier-free guidance is run as
|
| 131 |
+
two serial U-Net passes to reduce peak memory.
|
| 132 |
|
| 133 |
+
## LoRA compatibility
|
| 134 |
+
|
| 135 |
+
Diffusers can load a LoRA trained against this nine-channel inpainting U-Net.
|
| 136 |
+
Regular Clover Image Tiny LoRAs target a four-channel U-Net and are not
|
| 137 |
+
interchangeable. The Core ML package does not dynamically load inpainting
|
| 138 |
+
LoRAs; fuse an inpainting-specific adapter before conversion if needed.
|
| 139 |
|
| 140 |
+
## Limitations
|
| 141 |
+
|
| 142 |
+
The model can still distort small, highly structured objects, text, hands, and
|
| 143 |
+
faces. Very small masks may not provide enough latent resolution without the
|
| 144 |
+
recommended context crop. Results depend on the source, mask, prompt, seed,
|
| 145 |
+
and scheduler. This release inherits the limitations and license obligations
|
| 146 |
+
of its base and teacher models.
|
| 147 |
+
|
| 148 |
+
## Citation
|
| 149 |
|
| 150 |
```bibtex
|
| 151 |
@software{lozadaperez2026cloverimagetinyinpaint,
|
examples/bicycle-mask.png
ADDED
|
examples/{result-cat.png → bicycle-result.png}
RENAMED
|
File without changes
|
examples/{source-greenhouse.png → bicycle-source.png}
RENAMED
|
File without changes
|
examples/cat-mask.png
ADDED
|
examples/cat-result.png
ADDED
|
Git LFS Details
|
examples/cat-source.png
ADDED
|
Git LFS Details
|
examples/kettle-mask.png
ADDED
|
examples/kettle-result.png
ADDED
|
Git LFS Details
|
examples/kettle-source.png
ADDED
|
Git LFS Details
|
examples/mask-doorway.png
DELETED
|
Binary file (782 Bytes)
|
|
|
inpainting-config.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
| 1 |
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
"unet_in_channels": 9,
|
| 3 |
"mask_semantics": "white=regenerate, black=preserve",
|
| 4 |
-
"
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"recipe_version": 2,
|
| 3 |
+
"base_model": "neonforestmist/Clover-Image-Tiny",
|
| 4 |
+
"base_revision": "63b0e9f6be9c00888ff464f342a9ef052bf76681",
|
| 5 |
+
"initial_inpaint_model": "neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 6 |
+
"initial_inpaint_revision": "1b6f8ae3db51900520369d5522c7dc7c2a97e21e",
|
| 7 |
+
"teacher_model": "stable-diffusion-v1-5/stable-diffusion-inpainting",
|
| 8 |
+
"teacher_revision": "8a4288a76071f7280aedbdb3253bdb9e9d5d84bb",
|
| 9 |
+
"dataset": "prithivMLmods/Caption3o-Opt",
|
| 10 |
+
"dataset_revision": "17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b",
|
| 11 |
+
"dataset_split": "train",
|
| 12 |
+
"validation_samples": 128,
|
| 13 |
+
"image_column": "image",
|
| 14 |
+
"caption_column": "caption",
|
| 15 |
+
"resolution": 512,
|
| 16 |
"unet_in_channels": 9,
|
| 17 |
"mask_semantics": "white=regenerate, black=preserve",
|
| 18 |
+
"mask_distribution": [
|
| 19 |
+
"brush",
|
| 20 |
+
"multi_brush",
|
| 21 |
+
"rectangle",
|
| 22 |
+
"ellipse",
|
| 23 |
+
"polygon",
|
| 24 |
+
"multi_region",
|
| 25 |
+
"outpaint"
|
| 26 |
+
],
|
| 27 |
+
"training": {
|
| 28 |
+
"steps": 500,
|
| 29 |
+
"learning_rate": 0.000005,
|
| 30 |
+
"warmup_steps": 25,
|
| 31 |
+
"train_batch_size": 1,
|
| 32 |
+
"gradient_accumulation_steps": 4,
|
| 33 |
+
"effective_batch_size": 4,
|
| 34 |
+
"mixed_precision": "bf16",
|
| 35 |
+
"gradient_checkpointing": true,
|
| 36 |
+
"caption_dropout_probability": 0.1,
|
| 37 |
+
"mask_min_area": 0.04,
|
| 38 |
+
"mask_max_area": 0.65,
|
| 39 |
+
"snr_gamma": 5.0,
|
| 40 |
+
"teacher_loss_weight": 0.75,
|
| 41 |
+
"ground_truth_loss_weight": 0.25,
|
| 42 |
+
"context_loss_weight": 0.25,
|
| 43 |
+
"masked_loss_weight": 2.5,
|
| 44 |
+
"boundary_loss_weight": 2.0,
|
| 45 |
+
"checkpointing_steps": 500,
|
| 46 |
+
"checkpoints_total_limit": 3,
|
| 47 |
+
"seed": 20260811
|
| 48 |
+
},
|
| 49 |
+
"modal": {
|
| 50 |
+
"profile": "guccichungus69",
|
| 51 |
+
"gpu": "A10",
|
| 52 |
+
"timeout_hours": 2,
|
| 53 |
+
"output_name": "clover-image-tiny-inpaint-v2-pilot-500"
|
| 54 |
+
},
|
| 55 |
+
"recommended_inference": {
|
| 56 |
+
"scheduler": "DPMSolverMultistepScheduler",
|
| 57 |
+
"steps": 20,
|
| 58 |
+
"guidance_scale": 6.0,
|
| 59 |
+
"mask_crop_padding": 96,
|
| 60 |
+
"retry_full_frame": true,
|
| 61 |
+
"composite_outside_mask": "exact source pixels"
|
| 62 |
+
}
|
| 63 |
}
|
inpainting/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
---
|
| 2 |
library_name: diffusers
|
| 3 |
-
pipeline_tag: image-
|
| 4 |
base_model: neonforestmist/Clover-Image-Tiny
|
| 5 |
license: creativeml-openrail-m
|
| 6 |
tags:
|
|
@@ -8,14 +8,14 @@ tags:
|
|
| 8 |
- inpainting
|
| 9 |
- stable-diffusion
|
| 10 |
- coreml
|
| 11 |
-
- iphone
|
| 12 |
---
|
| 13 |
|
| 14 |
# Clover Image Tiny Inpaint 🍀
|
| 15 |
|
| 16 |
An inpainting adaptation of Clover Image Tiny for 512×512 local generation and
|
| 17 |
on-device Core ML deployment. White mask pixels are regenerated; black pixels
|
| 18 |
-
are preserved.
|
|
|
|
| 19 |
|
| 20 |
The model uses a 9-channel U-Net input:
|
| 21 |
|
|
@@ -25,32 +25,54 @@ The model uses a 9-channel U-Net input:
|
|
| 25 |
|
| 26 |
The base text encoder, VAE, scheduler, safety checker, and tokenizer remain
|
| 27 |
compatible with Clover Image Tiny. The inpainting export additionally includes
|
| 28 |
-
the VAE encoder needed to prepare the masked-image latent on
|
| 29 |
|
| 30 |
-
## Diffusers
|
| 31 |
|
| 32 |
```python
|
| 33 |
-
from diffusers import AutoPipelineForInpainting
|
| 34 |
from diffusers.utils import load_image
|
| 35 |
|
| 36 |
pipe = AutoPipelineForInpainting.from_pretrained(
|
| 37 |
"neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 38 |
torch_dtype="auto",
|
| 39 |
)
|
|
|
|
| 40 |
image = pipe(
|
| 41 |
prompt="a tiny glass greenhouse glowing in a moonlit garden",
|
| 42 |
image=load_image("input.png"),
|
| 43 |
mask_image=load_image("mask.png"),
|
| 44 |
-
num_inference_steps=
|
|
|
|
|
|
|
| 45 |
).images[0]
|
| 46 |
image.save("clover-inpaint.png")
|
| 47 |
```
|
| 48 |
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
The companion Core ML resource bundle is converted for iOS 18 with a
|
| 52 |
-
batch-one U-Net and chunked U-Net resources
|
| 53 |
-
classifier-free guidance as two
|
|
|
|
| 54 |
bundled `VAEEncoder.mlmodelc` creates the masked-image latent locally, so the
|
| 55 |
input image and mask do not leave the device.
|
| 56 |
|
|
@@ -62,7 +84,29 @@ Conversion and the native iOS integration live in the source Clover repo:
|
|
| 62 |
|
| 63 |
## Training provenance
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
library_name: diffusers
|
| 3 |
+
pipeline_tag: image-to-image
|
| 4 |
base_model: neonforestmist/Clover-Image-Tiny
|
| 5 |
license: creativeml-openrail-m
|
| 6 |
tags:
|
|
|
|
| 8 |
- inpainting
|
| 9 |
- stable-diffusion
|
| 10 |
- coreml
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
# Clover Image Tiny Inpaint 🍀
|
| 14 |
|
| 15 |
An inpainting adaptation of Clover Image Tiny for 512×512 local generation and
|
| 16 |
on-device Core ML deployment. White mask pixels are regenerated; black pixels
|
| 17 |
+
are preserved. The intended target is an SD 1.4-class architecture, rather than
|
| 18 |
+
a device-specific model.
|
| 19 |
|
| 20 |
The model uses a 9-channel U-Net input:
|
| 21 |
|
|
|
|
| 25 |
|
| 26 |
The base text encoder, VAE, scheduler, safety checker, and tokenizer remain
|
| 27 |
compatible with Clover Image Tiny. The inpainting export additionally includes
|
| 28 |
+
the VAE encoder needed to prepare the masked-image latent on device.
|
| 29 |
|
| 30 |
+
## Diffusers example
|
| 31 |
|
| 32 |
```python
|
| 33 |
+
from diffusers import AutoPipelineForInpainting, DPMSolverMultistepScheduler
|
| 34 |
from diffusers.utils import load_image
|
| 35 |
|
| 36 |
pipe = AutoPipelineForInpainting.from_pretrained(
|
| 37 |
"neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 38 |
torch_dtype="auto",
|
| 39 |
)
|
| 40 |
+
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
|
| 41 |
image = pipe(
|
| 42 |
prompt="a tiny glass greenhouse glowing in a moonlit garden",
|
| 43 |
image=load_image("input.png"),
|
| 44 |
mask_image=load_image("mask.png"),
|
| 45 |
+
num_inference_steps=20,
|
| 46 |
+
guidance_scale=6.0,
|
| 47 |
+
padding_mask_crop=96,
|
| 48 |
).images[0]
|
| 49 |
image.save("clover-inpaint.png")
|
| 50 |
```
|
| 51 |
|
| 52 |
+
The mask is a grayscale image: white means “regenerate” and black means
|
| 53 |
+
“preserve.” A few useful inpainting prompts are:
|
| 54 |
+
|
| 55 |
+
- `replace the masked area with a tiny glass greenhouse glowing at night`
|
| 56 |
+
- `remove the person from the masked area and continue the background naturally`
|
| 57 |
+
- `add a red enamel kettle on the masked countertop`
|
| 58 |
+
|
| 59 |
+
Use DPM-Solver++, 20 steps, CFG 6.0, and a 96-pixel context margin as the
|
| 60 |
+
interactive defaults. Exact-mask compositing preserves every source pixel
|
| 61 |
+
outside the edit. The selected v2 checkpoint reduced deterministic held-out
|
| 62 |
+
masked MAE from 0.3120 to 0.2517 and improved mean semantic CLIP alignment from
|
| 63 |
+
0.2726 to 0.2890 over the previous release.
|
| 64 |
+
|
| 65 |
+
For repeatable experiments, keep the input image, mask, seed, scheduler, and
|
| 66 |
+
step count together. Inpainting is local editing: the unmasked region is
|
| 67 |
+
provided as the masked-image conditioning and is also preserved by the native
|
| 68 |
+
runtime compositor.
|
| 69 |
+
|
| 70 |
+
## Core ML and SD 1.4-class deployment
|
| 71 |
|
| 72 |
The companion Core ML resource bundle is converted for iOS 18 with a
|
| 73 |
+
batch-one U-Net and chunked U-Net resources for the SD 1.4-class 512×512
|
| 74 |
+
architecture. The Swift runtime performs classifier-free guidance as two
|
| 75 |
+
serial passes to reduce peak memory. The
|
| 76 |
bundled `VAEEncoder.mlmodelc` creates the masked-image latent locally, so the
|
| 77 |
input image and mask do not leave the device.
|
| 78 |
|
|
|
|
| 84 |
|
| 85 |
## Training provenance
|
| 86 |
|
| 87 |
+
The context-aware v2 recipe distills the pinned official SD 1.5 inpainting
|
| 88 |
+
U-Net into Clover's compact nine-channel U-Net while retaining a ground-truth
|
| 89 |
+
diffusion loss. Training uses diverse synthetic free-form, multi-region,
|
| 90 |
+
object-like, and outpainting masks over the pinned Apache-2.0
|
| 91 |
+
`prithivMLmods/Caption3o-Opt` image-caption dataset. A deterministic 128-image
|
| 92 |
+
holdout is excluded from optimization and reused for same-mask, same-seed
|
| 93 |
+
comparisons against the previous release.
|
| 94 |
+
|
| 95 |
+
The job runs on Modal under the `guccichungus69` workspace and stores rolling
|
| 96 |
+
resumable checkpoints plus the final Diffusers pipeline in the
|
| 97 |
+
`clover-image-tiny-inpaint-output` Volume before Core ML conversion. Exact
|
| 98 |
+
revisions and objective weights are recorded in `inpainting/config.json` and
|
| 99 |
+
the generated `training-summary.json`.
|
| 100 |
+
|
| 101 |
+
## Citation
|
| 102 |
+
|
| 103 |
+
If Clover Image Tiny Inpaint is useful in your work, please cite the release:
|
| 104 |
+
|
| 105 |
+
```bibtex
|
| 106 |
+
@software{lozadaperez2026cloverimagetinyinpaint,
|
| 107 |
+
author = {Lukas Lozada Perez},
|
| 108 |
+
title = {Clover Image Tiny Inpaint: Compact SD 1.4-Class Image Inpainting},
|
| 109 |
+
year = {2026},
|
| 110 |
+
url = {https://huggingface.co/neonforestmist/Clover-Image-Tiny-Inpaint}
|
| 111 |
+
}
|
| 112 |
+
```
|
inpainting/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
"""Clover Image Tiny inpainting training and export helpers."""
|
| 2 |
|
| 3 |
-
__all__ = ["
|
|
|
|
| 1 |
"""Clover Image Tiny inpainting training and export helpers."""
|
| 2 |
|
| 3 |
+
__all__ = ["masks", "model"]
|
inpainting/__pycache__/__init__.cpython-311.pyc
DELETED
|
Binary file (241 Bytes)
|
|
|
inpainting/__pycache__/masks.cpython-311.pyc
DELETED
|
Binary file (3.88 kB)
|
|
|
inpainting/__pycache__/model.cpython-311.pyc
DELETED
|
Binary file (2.92 kB)
|
|
|
inpainting/__pycache__/train.cpython-311.pyc
DELETED
|
Binary file (19.4 kB)
|
|
|
inpainting/config.json
CHANGED
|
@@ -1,29 +1,63 @@
|
|
| 1 |
{
|
|
|
|
| 2 |
"base_model": "neonforestmist/Clover-Image-Tiny",
|
| 3 |
"base_revision": "63b0e9f6be9c00888ff464f342a9ef052bf76681",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"dataset": "prithivMLmods/Caption3o-Opt",
|
| 5 |
"dataset_revision": "17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b",
|
| 6 |
"dataset_split": "train",
|
|
|
|
| 7 |
"image_column": "image",
|
| 8 |
"caption_column": "caption",
|
| 9 |
"resolution": 512,
|
| 10 |
"unet_in_channels": 9,
|
| 11 |
"mask_semantics": "white=regenerate, black=preserve",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"training": {
|
| 13 |
-
"steps":
|
| 14 |
-
"learning_rate": 0.
|
| 15 |
-
"warmup_steps":
|
| 16 |
"train_batch_size": 1,
|
| 17 |
"gradient_accumulation_steps": 4,
|
| 18 |
-
"
|
|
|
|
| 19 |
"gradient_checkpointing": true,
|
| 20 |
-
"
|
| 21 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
},
|
| 23 |
"modal": {
|
| 24 |
"profile": "guccichungus69",
|
| 25 |
-
"gpu": "
|
| 26 |
-
"timeout_hours":
|
| 27 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"recipe_version": 2,
|
| 3 |
"base_model": "neonforestmist/Clover-Image-Tiny",
|
| 4 |
"base_revision": "63b0e9f6be9c00888ff464f342a9ef052bf76681",
|
| 5 |
+
"initial_inpaint_model": "neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 6 |
+
"initial_inpaint_revision": "1b6f8ae3db51900520369d5522c7dc7c2a97e21e",
|
| 7 |
+
"teacher_model": "stable-diffusion-v1-5/stable-diffusion-inpainting",
|
| 8 |
+
"teacher_revision": "8a4288a76071f7280aedbdb3253bdb9e9d5d84bb",
|
| 9 |
"dataset": "prithivMLmods/Caption3o-Opt",
|
| 10 |
"dataset_revision": "17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b",
|
| 11 |
"dataset_split": "train",
|
| 12 |
+
"validation_samples": 128,
|
| 13 |
"image_column": "image",
|
| 14 |
"caption_column": "caption",
|
| 15 |
"resolution": 512,
|
| 16 |
"unet_in_channels": 9,
|
| 17 |
"mask_semantics": "white=regenerate, black=preserve",
|
| 18 |
+
"mask_distribution": [
|
| 19 |
+
"brush",
|
| 20 |
+
"multi_brush",
|
| 21 |
+
"rectangle",
|
| 22 |
+
"ellipse",
|
| 23 |
+
"polygon",
|
| 24 |
+
"multi_region",
|
| 25 |
+
"outpaint"
|
| 26 |
+
],
|
| 27 |
"training": {
|
| 28 |
+
"steps": 500,
|
| 29 |
+
"learning_rate": 0.000005,
|
| 30 |
+
"warmup_steps": 25,
|
| 31 |
"train_batch_size": 1,
|
| 32 |
"gradient_accumulation_steps": 4,
|
| 33 |
+
"effective_batch_size": 4,
|
| 34 |
+
"mixed_precision": "bf16",
|
| 35 |
"gradient_checkpointing": true,
|
| 36 |
+
"caption_dropout_probability": 0.1,
|
| 37 |
+
"mask_min_area": 0.04,
|
| 38 |
+
"mask_max_area": 0.65,
|
| 39 |
+
"snr_gamma": 5.0,
|
| 40 |
+
"teacher_loss_weight": 0.75,
|
| 41 |
+
"ground_truth_loss_weight": 0.25,
|
| 42 |
+
"context_loss_weight": 0.25,
|
| 43 |
+
"masked_loss_weight": 2.5,
|
| 44 |
+
"boundary_loss_weight": 2.0,
|
| 45 |
+
"checkpointing_steps": 500,
|
| 46 |
+
"checkpoints_total_limit": 3,
|
| 47 |
+
"seed": 20260811
|
| 48 |
},
|
| 49 |
"modal": {
|
| 50 |
"profile": "guccichungus69",
|
| 51 |
+
"gpu": "A10",
|
| 52 |
+
"timeout_hours": 2,
|
| 53 |
+
"output_name": "clover-image-tiny-inpaint-v2-pilot-500"
|
| 54 |
+
},
|
| 55 |
+
"recommended_inference": {
|
| 56 |
+
"scheduler": "DPMSolverMultistepScheduler",
|
| 57 |
+
"steps": 20,
|
| 58 |
+
"guidance_scale": 6.0,
|
| 59 |
+
"mask_crop_padding": 96,
|
| 60 |
+
"retry_full_frame": true,
|
| 61 |
+
"composite_outside_mask": "exact source pixels"
|
| 62 |
}
|
| 63 |
}
|
inpainting/evaluate.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Compare two Clover inpainting checkpoints on held-out reconstruction masks."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import gc
|
| 8 |
+
import json
|
| 9 |
+
import random
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
from datasets import load_dataset
|
| 16 |
+
from diffusers import DPMSolverMultistepScheduler, StableDiffusionInpaintPipeline
|
| 17 |
+
from PIL import Image, ImageDraw, ImageFilter, ImageOps
|
| 18 |
+
|
| 19 |
+
from inpainting.masks import mask_area_fraction, random_mask
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def parse_args() -> argparse.Namespace:
|
| 23 |
+
parser = argparse.ArgumentParser()
|
| 24 |
+
parser.add_argument("--baseline_model", required=True)
|
| 25 |
+
parser.add_argument("--baseline_revision")
|
| 26 |
+
parser.add_argument("--candidate_model", required=True)
|
| 27 |
+
parser.add_argument("--dataset_name", required=True)
|
| 28 |
+
parser.add_argument("--dataset_revision")
|
| 29 |
+
parser.add_argument("--dataset_split", default="train")
|
| 30 |
+
parser.add_argument("--image_column", default="image")
|
| 31 |
+
parser.add_argument("--caption_column", default="caption")
|
| 32 |
+
parser.add_argument("--validation_samples", type=int, default=128)
|
| 33 |
+
parser.add_argument("--sample_count", type=int, default=6)
|
| 34 |
+
parser.add_argument("--steps", type=int, default=30)
|
| 35 |
+
parser.add_argument("--guidance_scale", type=float, default=7.5)
|
| 36 |
+
parser.add_argument("--seed", type=int, default=20260811)
|
| 37 |
+
parser.add_argument("--output_dir", type=Path, required=True)
|
| 38 |
+
return parser.parse_args()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _caption(value: Any) -> str:
|
| 42 |
+
if isinstance(value, list):
|
| 43 |
+
value = value[0] if value else ""
|
| 44 |
+
return " ".join(str(value or "").split())
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _prepare_image(image: Image.Image, resolution: int = 512) -> Image.Image:
|
| 48 |
+
image = ImageOps.exif_transpose(image).convert("RGB")
|
| 49 |
+
side = min(image.size)
|
| 50 |
+
left = (image.width - side) // 2
|
| 51 |
+
top = (image.height - side) // 2
|
| 52 |
+
return image.crop((left, top, left + side, top + side)).resize(
|
| 53 |
+
(resolution, resolution), Image.Resampling.LANCZOS
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _feather_inside(mask: Image.Image, radius: int = 6) -> Image.Image:
|
| 58 |
+
binary = mask.convert("L").point(lambda value: 255 if value >= 128 else 0)
|
| 59 |
+
softened = binary.filter(ImageFilter.GaussianBlur(radius=radius))
|
| 60 |
+
return Image.composite(softened, Image.new("L", binary.size, 0), binary)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _composite(generated: Image.Image, source: Image.Image, mask: Image.Image) -> Image.Image:
|
| 64 |
+
return Image.composite(generated.convert("RGB"), source.convert("RGB"), _feather_inside(mask))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _masked_mae(result: Image.Image, target: Image.Image, mask: Image.Image) -> float:
|
| 68 |
+
result_array = np.asarray(result, dtype=np.float32) / 255.0
|
| 69 |
+
target_array = np.asarray(target, dtype=np.float32) / 255.0
|
| 70 |
+
mask_array = np.asarray(mask.convert("L"), dtype=np.float32) / 255.0
|
| 71 |
+
denominator = max(1.0, mask_array.sum() * 3.0)
|
| 72 |
+
return float((np.abs(result_array - target_array) * mask_array[..., None]).sum() / denominator)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _black_fraction(image: Image.Image, mask: Image.Image) -> float:
|
| 76 |
+
pixels = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
| 77 |
+
selected = np.asarray(mask.convert("L")) >= 128
|
| 78 |
+
if not selected.any():
|
| 79 |
+
return 1.0
|
| 80 |
+
return float(np.all(pixels[selected] <= 8, axis=1).mean())
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _load_pipeline(model: str, *, revision: str | None = None):
|
| 84 |
+
kwargs = {"revision": revision} if revision else {}
|
| 85 |
+
pipeline = StableDiffusionInpaintPipeline.from_pretrained(
|
| 86 |
+
model,
|
| 87 |
+
torch_dtype=torch.float16,
|
| 88 |
+
safety_checker=None,
|
| 89 |
+
requires_safety_checker=False,
|
| 90 |
+
**kwargs,
|
| 91 |
+
)
|
| 92 |
+
pipeline.scheduler = DPMSolverMultistepScheduler.from_config(
|
| 93 |
+
pipeline.scheduler.config,
|
| 94 |
+
algorithm_type="dpmsolver++",
|
| 95 |
+
)
|
| 96 |
+
return pipeline.to("cuda")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _run_model(
|
| 100 |
+
*,
|
| 101 |
+
model_name: str,
|
| 102 |
+
revision: str | None,
|
| 103 |
+
cases: list[dict[str, Any]],
|
| 104 |
+
steps: int,
|
| 105 |
+
guidance_scale: float,
|
| 106 |
+
output_dir: Path,
|
| 107 |
+
) -> list[dict[str, Any]]:
|
| 108 |
+
pipeline = _load_pipeline(model_name, revision=revision)
|
| 109 |
+
records = []
|
| 110 |
+
for index, case in enumerate(cases):
|
| 111 |
+
generator = torch.Generator(device="cuda").manual_seed(case["seed"])
|
| 112 |
+
response = pipeline(
|
| 113 |
+
prompt=case["caption"],
|
| 114 |
+
image=case["source"],
|
| 115 |
+
mask_image=case["mask"],
|
| 116 |
+
num_inference_steps=steps,
|
| 117 |
+
guidance_scale=guidance_scale,
|
| 118 |
+
generator=generator,
|
| 119 |
+
height=512,
|
| 120 |
+
width=512,
|
| 121 |
+
)
|
| 122 |
+
raw = response.images[0].convert("RGB")
|
| 123 |
+
result = _composite(raw, case["source"], case["mask"])
|
| 124 |
+
result.save(output_dir / f"{index:02d}.png")
|
| 125 |
+
records.append(
|
| 126 |
+
{
|
| 127 |
+
"index": index,
|
| 128 |
+
"masked_mae": _masked_mae(result, case["source"], case["mask"]),
|
| 129 |
+
"black_fraction": _black_fraction(result, case["mask"]),
|
| 130 |
+
"nsfw_content_detected": None,
|
| 131 |
+
}
|
| 132 |
+
)
|
| 133 |
+
del pipeline
|
| 134 |
+
gc.collect()
|
| 135 |
+
torch.cuda.empty_cache()
|
| 136 |
+
return records
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _make_sheet(
|
| 140 |
+
cases: list[dict[str, Any]],
|
| 141 |
+
baseline_dir: Path,
|
| 142 |
+
candidate_dir: Path,
|
| 143 |
+
destination: Path,
|
| 144 |
+
) -> None:
|
| 145 |
+
cell = 512
|
| 146 |
+
label_height = 32
|
| 147 |
+
sheet = Image.new("RGB", (cell * 3, (cell + label_height) * len(cases)), "white")
|
| 148 |
+
draw = ImageDraw.Draw(sheet)
|
| 149 |
+
for index, case in enumerate(cases):
|
| 150 |
+
row_y = index * (cell + label_height)
|
| 151 |
+
source = case["source"].copy()
|
| 152 |
+
overlay = Image.new("RGB", source.size, (255, 255, 255))
|
| 153 |
+
source = Image.blend(source, Image.composite(overlay, source, case["mask"]), 0.55)
|
| 154 |
+
images = [
|
| 155 |
+
source,
|
| 156 |
+
Image.open(baseline_dir / f"{index:02d}.png").convert("RGB"),
|
| 157 |
+
Image.open(candidate_dir / f"{index:02d}.png").convert("RGB"),
|
| 158 |
+
]
|
| 159 |
+
for column, image in enumerate(images):
|
| 160 |
+
sheet.paste(image, (column * cell, row_y + label_height))
|
| 161 |
+
draw.text((8, row_y + 8), f"source + mask | {case['caption'][:58]}", fill="black")
|
| 162 |
+
draw.text((cell + 8, row_y + 8), "current checkpoint", fill="black")
|
| 163 |
+
draw.text((cell * 2 + 8, row_y + 8), "candidate", fill="black")
|
| 164 |
+
sheet.save(destination)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def main() -> None:
|
| 168 |
+
args = parse_args()
|
| 169 |
+
args.output_dir.mkdir(parents=True, exist_ok=False)
|
| 170 |
+
baseline_dir = args.output_dir / "baseline"
|
| 171 |
+
candidate_dir = args.output_dir / "candidate"
|
| 172 |
+
baseline_dir.mkdir()
|
| 173 |
+
candidate_dir.mkdir()
|
| 174 |
+
|
| 175 |
+
dataset = load_dataset(
|
| 176 |
+
args.dataset_name,
|
| 177 |
+
split=args.dataset_split,
|
| 178 |
+
revision=args.dataset_revision,
|
| 179 |
+
).shuffle(seed=args.seed)
|
| 180 |
+
if args.validation_samples < args.sample_count or args.validation_samples >= len(dataset):
|
| 181 |
+
raise ValueError("validation set must contain at least sample_count records")
|
| 182 |
+
validation = dataset.select(
|
| 183 |
+
range(len(dataset) - args.validation_samples, len(dataset))
|
| 184 |
+
).select(range(args.sample_count))
|
| 185 |
+
|
| 186 |
+
cases = []
|
| 187 |
+
for index, example in enumerate(validation):
|
| 188 |
+
image = example[args.image_column]
|
| 189 |
+
if not isinstance(image, Image.Image):
|
| 190 |
+
image = Image.fromarray(np.asarray(image))
|
| 191 |
+
source = _prepare_image(image)
|
| 192 |
+
rng = random.Random(args.seed + index * 1009)
|
| 193 |
+
mask = random_mask((512, 512), rng, min_area=0.08, max_area=0.45)
|
| 194 |
+
caption = _caption(example[args.caption_column])
|
| 195 |
+
cases.append(
|
| 196 |
+
{
|
| 197 |
+
"source": source,
|
| 198 |
+
"mask": mask,
|
| 199 |
+
"caption": caption,
|
| 200 |
+
"seed": args.seed + index,
|
| 201 |
+
"mask_area": mask_area_fraction(mask),
|
| 202 |
+
}
|
| 203 |
+
)
|
| 204 |
+
source.save(args.output_dir / f"source-{index:02d}.png")
|
| 205 |
+
mask.save(args.output_dir / f"mask-{index:02d}.png")
|
| 206 |
+
|
| 207 |
+
baseline_records = _run_model(
|
| 208 |
+
model_name=args.baseline_model,
|
| 209 |
+
revision=args.baseline_revision,
|
| 210 |
+
cases=cases,
|
| 211 |
+
steps=args.steps,
|
| 212 |
+
guidance_scale=args.guidance_scale,
|
| 213 |
+
output_dir=baseline_dir,
|
| 214 |
+
)
|
| 215 |
+
candidate_records = _run_model(
|
| 216 |
+
model_name=args.candidate_model,
|
| 217 |
+
revision=None,
|
| 218 |
+
cases=cases,
|
| 219 |
+
steps=args.steps,
|
| 220 |
+
guidance_scale=args.guidance_scale,
|
| 221 |
+
output_dir=candidate_dir,
|
| 222 |
+
)
|
| 223 |
+
_make_sheet(cases, baseline_dir, candidate_dir, args.output_dir / "comparison.png")
|
| 224 |
+
|
| 225 |
+
metrics = {
|
| 226 |
+
"baseline_model": args.baseline_model,
|
| 227 |
+
"baseline_revision": args.baseline_revision,
|
| 228 |
+
"candidate_model": args.candidate_model,
|
| 229 |
+
"dataset": args.dataset_name,
|
| 230 |
+
"dataset_revision": args.dataset_revision,
|
| 231 |
+
"sample_count": args.sample_count,
|
| 232 |
+
"steps": args.steps,
|
| 233 |
+
"baseline": baseline_records,
|
| 234 |
+
"candidate": candidate_records,
|
| 235 |
+
"baseline_mean_masked_mae": float(
|
| 236 |
+
np.mean([record["masked_mae"] for record in baseline_records])
|
| 237 |
+
),
|
| 238 |
+
"candidate_mean_masked_mae": float(
|
| 239 |
+
np.mean([record["masked_mae"] for record in candidate_records])
|
| 240 |
+
),
|
| 241 |
+
}
|
| 242 |
+
(args.output_dir / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n")
|
| 243 |
+
print(json.dumps(metrics, indent=2))
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
if __name__ == "__main__":
|
| 247 |
+
main()
|
inpainting/evaluate_semantic.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Compare semantic inpainting quality on controlled, context-rich scenes."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import gc
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
from diffusers import (
|
| 15 |
+
DPMSolverMultistepScheduler,
|
| 16 |
+
StableDiffusionInpaintPipeline,
|
| 17 |
+
StableDiffusionPipeline,
|
| 18 |
+
)
|
| 19 |
+
from PIL import Image, ImageDraw, ImageFilter
|
| 20 |
+
from transformers import CLIPModel, CLIPProcessor
|
| 21 |
+
|
| 22 |
+
SOURCE_CASES = (
|
| 23 |
+
{
|
| 24 |
+
"name": "park-bench-cat",
|
| 25 |
+
"source_prompt": (
|
| 26 |
+
"a detailed photograph of an empty wooden park bench centered in a leafy "
|
| 27 |
+
"park, no people, no animals"
|
| 28 |
+
),
|
| 29 |
+
"edit_prompt": (
|
| 30 |
+
"a tabby cat sitting naturally on the wooden park bench, detailed photography"
|
| 31 |
+
),
|
| 32 |
+
"score_prompt": "a detailed photograph of a tabby cat",
|
| 33 |
+
"mask": (148, 224, 366, 400),
|
| 34 |
+
"shape": "ellipse",
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"name": "kitchen-kettle",
|
| 38 |
+
"source_prompt": (
|
| 39 |
+
"a detailed photograph of an empty kitchen countertop viewed straight on, "
|
| 40 |
+
"warm daylight, no objects in the center"
|
| 41 |
+
),
|
| 42 |
+
"edit_prompt": (
|
| 43 |
+
"a glossy red enamel kettle resting naturally on the kitchen countertop, "
|
| 44 |
+
"detailed photography"
|
| 45 |
+
),
|
| 46 |
+
"score_prompt": "a detailed photograph of a glossy red enamel kettle",
|
| 47 |
+
"mask": (166, 236, 350, 414),
|
| 48 |
+
"shape": "rounded_rectangle",
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"name": "garden-greenhouse",
|
| 52 |
+
"source_prompt": (
|
| 53 |
+
"a realistic moonlit garden with an empty grassy clearing in the center, "
|
| 54 |
+
"lush plants around the clearing"
|
| 55 |
+
),
|
| 56 |
+
"edit_prompt": (
|
| 57 |
+
"a tiny glass greenhouse glowing warmly in the moonlit garden clearing, "
|
| 58 |
+
"detailed photography"
|
| 59 |
+
),
|
| 60 |
+
"score_prompt": "a detailed photograph of a tiny glass greenhouse",
|
| 61 |
+
"mask": ((170, 400), (147, 245), (193, 161), (321, 154), (372, 246), (350, 404)),
|
| 62 |
+
"shape": "polygon",
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"name": "street-bicycle",
|
| 66 |
+
"source_prompt": (
|
| 67 |
+
"a detailed photograph of a quiet city street with an empty road in the "
|
| 68 |
+
"foreground, late afternoon"
|
| 69 |
+
),
|
| 70 |
+
"edit_prompt": (
|
| 71 |
+
"a bright red bicycle standing naturally on the city street, detailed photography"
|
| 72 |
+
),
|
| 73 |
+
"score_prompt": "a detailed photograph of a bright red bicycle",
|
| 74 |
+
"mask": ((146, 378), (206, 318), (288, 385), (366, 327)),
|
| 75 |
+
"shape": "brush",
|
| 76 |
+
"width": 92,
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"name": "living-room-dog",
|
| 80 |
+
"source_prompt": (
|
| 81 |
+
"a detailed photograph of a cozy living room with an empty rug centered on the "
|
| 82 |
+
"floor, soft window light"
|
| 83 |
+
),
|
| 84 |
+
"edit_prompt": (
|
| 85 |
+
"a small corgi sitting naturally on the living room rug, detailed photography"
|
| 86 |
+
),
|
| 87 |
+
"score_prompt": "a detailed photograph of a small corgi dog",
|
| 88 |
+
"mask": (156, 252, 360, 450),
|
| 89 |
+
"shape": "ellipse",
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"name": "lake-swan",
|
| 93 |
+
"source_prompt": (
|
| 94 |
+
"a detailed photograph of a calm lake with empty water near the foreground, "
|
| 95 |
+
"mountains in the distance"
|
| 96 |
+
),
|
| 97 |
+
"edit_prompt": (
|
| 98 |
+
"a white swan floating naturally on the calm lake water, detailed photography"
|
| 99 |
+
),
|
| 100 |
+
"score_prompt": "a detailed photograph of a white swan",
|
| 101 |
+
"mask": ((151, 367), (208, 314), (272, 382), (355, 328)),
|
| 102 |
+
"shape": "brush",
|
| 103 |
+
"width": 96,
|
| 104 |
+
},
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def parse_args() -> argparse.Namespace:
|
| 109 |
+
parser = argparse.ArgumentParser()
|
| 110 |
+
parser.add_argument("--base_model", required=True)
|
| 111 |
+
parser.add_argument("--base_revision")
|
| 112 |
+
parser.add_argument("--baseline_model", required=True)
|
| 113 |
+
parser.add_argument("--baseline_revision")
|
| 114 |
+
parser.add_argument("--teacher_model", required=True)
|
| 115 |
+
parser.add_argument("--teacher_revision")
|
| 116 |
+
parser.add_argument("--teacher_variant")
|
| 117 |
+
parser.add_argument("--candidate_model", required=True)
|
| 118 |
+
parser.add_argument("--clip_model", default="openai/clip-vit-base-patch32")
|
| 119 |
+
parser.add_argument("--clip_revision")
|
| 120 |
+
parser.add_argument("--steps", type=int, default=30)
|
| 121 |
+
parser.add_argument("--guidance_scale", type=float, default=7.5)
|
| 122 |
+
parser.add_argument("--mask_crop_padding", type=int, default=0)
|
| 123 |
+
parser.add_argument("--seed", type=int, default=20260811)
|
| 124 |
+
parser.add_argument("--output_dir", type=Path, required=True)
|
| 125 |
+
return parser.parse_args()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _scheduler(pipeline: Any) -> DPMSolverMultistepScheduler:
|
| 129 |
+
return DPMSolverMultistepScheduler.from_config(
|
| 130 |
+
pipeline.scheduler.config,
|
| 131 |
+
algorithm_type="dpmsolver++",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _release_cuda(model: Any) -> None:
|
| 136 |
+
if isinstance(model, torch.nn.Module):
|
| 137 |
+
model.to("cpu")
|
| 138 |
+
else:
|
| 139 |
+
for component in getattr(model, "components", {}).values():
|
| 140 |
+
if isinstance(component, torch.nn.Module):
|
| 141 |
+
component.to("cpu")
|
| 142 |
+
del model
|
| 143 |
+
gc.collect()
|
| 144 |
+
torch.cuda.empty_cache()
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _mask(case: dict[str, Any]) -> Image.Image:
|
| 148 |
+
mask = Image.new("L", (512, 512), 0)
|
| 149 |
+
draw = ImageDraw.Draw(mask)
|
| 150 |
+
if case["shape"] == "ellipse":
|
| 151 |
+
draw.ellipse(case["mask"], fill=255)
|
| 152 |
+
elif case["shape"] == "rounded_rectangle":
|
| 153 |
+
draw.rounded_rectangle(case["mask"], radius=28, fill=255)
|
| 154 |
+
elif case["shape"] == "polygon":
|
| 155 |
+
draw.polygon(case["mask"], fill=255)
|
| 156 |
+
elif case["shape"] == "brush":
|
| 157 |
+
points = case["mask"]
|
| 158 |
+
width = int(case["width"])
|
| 159 |
+
draw.line(points, fill=255, width=width, joint="curve")
|
| 160 |
+
radius = width // 2
|
| 161 |
+
for x, y in points:
|
| 162 |
+
draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=255)
|
| 163 |
+
else:
|
| 164 |
+
raise ValueError(f"Unsupported semantic mask shape: {case['shape']}")
|
| 165 |
+
return mask
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _feather_inside(mask: Image.Image, radius: int = 6) -> Image.Image:
|
| 169 |
+
binary = mask.convert("L").point(lambda value: 255 if value >= 128 else 0)
|
| 170 |
+
softened = binary.filter(ImageFilter.GaussianBlur(radius=radius))
|
| 171 |
+
return Image.composite(softened, Image.new("L", binary.size, 0), binary)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _composite(generated: Image.Image, source: Image.Image, mask: Image.Image) -> Image.Image:
|
| 175 |
+
return Image.composite(generated.convert("RGB"), source, _feather_inside(mask))
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _square_crop_bounds(mask: Image.Image, padding: int) -> tuple[int, int, int, int]:
|
| 179 |
+
bounds = mask.convert("L").getbbox()
|
| 180 |
+
if bounds is None:
|
| 181 |
+
raise ValueError("Cannot crop around an empty mask")
|
| 182 |
+
left, top, right, bottom = bounds
|
| 183 |
+
side = min(
|
| 184 |
+
max(mask.size),
|
| 185 |
+
max(right - left, bottom - top) + max(0, padding) * 2,
|
| 186 |
+
)
|
| 187 |
+
center_x = (left + right) / 2
|
| 188 |
+
center_y = (top + bottom) / 2
|
| 189 |
+
crop_left = round(center_x - side / 2)
|
| 190 |
+
crop_top = round(center_y - side / 2)
|
| 191 |
+
crop_left = min(max(0, crop_left), mask.width - side)
|
| 192 |
+
crop_top = min(max(0, crop_top), mask.height - side)
|
| 193 |
+
return crop_left, crop_top, crop_left + side, crop_top + side
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _generate_sources(args: argparse.Namespace, cases: list[dict[str, Any]]) -> None:
|
| 197 |
+
kwargs = {"revision": args.base_revision} if args.base_revision else {}
|
| 198 |
+
pipeline = StableDiffusionPipeline.from_pretrained(
|
| 199 |
+
args.base_model,
|
| 200 |
+
torch_dtype=torch.float16,
|
| 201 |
+
safety_checker=None,
|
| 202 |
+
requires_safety_checker=False,
|
| 203 |
+
**kwargs,
|
| 204 |
+
).to("cuda")
|
| 205 |
+
pipeline.scheduler = _scheduler(pipeline)
|
| 206 |
+
negative = "people, animals, object in the center, blurry, distorted, low detail"
|
| 207 |
+
for index, case in enumerate(cases):
|
| 208 |
+
generator = torch.Generator(device="cuda").manual_seed(args.seed + index)
|
| 209 |
+
response = pipeline(
|
| 210 |
+
prompt=case["source_prompt"],
|
| 211 |
+
negative_prompt=negative,
|
| 212 |
+
num_inference_steps=args.steps,
|
| 213 |
+
guidance_scale=args.guidance_scale,
|
| 214 |
+
width=512,
|
| 215 |
+
height=512,
|
| 216 |
+
generator=generator,
|
| 217 |
+
)
|
| 218 |
+
case["source"] = response.images[0].convert("RGB")
|
| 219 |
+
case["mask_image"] = _mask(case)
|
| 220 |
+
case["source"].save(args.output_dir / f"source-{index:02d}-{case['name']}.png")
|
| 221 |
+
case["mask_image"].save(args.output_dir / f"mask-{index:02d}-{case['name']}.png")
|
| 222 |
+
_release_cuda(pipeline)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _run_inpainting_model(
|
| 226 |
+
*,
|
| 227 |
+
model_name: str,
|
| 228 |
+
revision: str | None,
|
| 229 |
+
variant: str | None,
|
| 230 |
+
cases: list[dict[str, Any]],
|
| 231 |
+
args: argparse.Namespace,
|
| 232 |
+
output_dir: Path,
|
| 233 |
+
) -> list[Image.Image]:
|
| 234 |
+
kwargs = {"revision": revision} if revision else {}
|
| 235 |
+
if variant:
|
| 236 |
+
kwargs.update({"variant": variant, "use_safetensors": True})
|
| 237 |
+
pipeline = StableDiffusionInpaintPipeline.from_pretrained(
|
| 238 |
+
model_name,
|
| 239 |
+
torch_dtype=torch.float16,
|
| 240 |
+
safety_checker=None,
|
| 241 |
+
requires_safety_checker=False,
|
| 242 |
+
**kwargs,
|
| 243 |
+
).to("cuda")
|
| 244 |
+
pipeline.scheduler = _scheduler(pipeline)
|
| 245 |
+
results = []
|
| 246 |
+
for index, case in enumerate(cases):
|
| 247 |
+
source = case["source"]
|
| 248 |
+
mask_image = case["mask_image"]
|
| 249 |
+
crop_bounds = None
|
| 250 |
+
pipeline_image = source
|
| 251 |
+
pipeline_mask = mask_image
|
| 252 |
+
if args.mask_crop_padding > 0:
|
| 253 |
+
crop_bounds = _square_crop_bounds(mask_image, args.mask_crop_padding)
|
| 254 |
+
pipeline_image = source.crop(crop_bounds).resize(
|
| 255 |
+
(512, 512), Image.Resampling.LANCZOS
|
| 256 |
+
)
|
| 257 |
+
pipeline_mask = mask_image.crop(crop_bounds).resize(
|
| 258 |
+
(512, 512), Image.Resampling.NEAREST
|
| 259 |
+
)
|
| 260 |
+
generator = torch.Generator(device="cuda").manual_seed(args.seed + 10_000 + index)
|
| 261 |
+
response = pipeline(
|
| 262 |
+
prompt=case["edit_prompt"],
|
| 263 |
+
negative_prompt="black patch, blurry, distorted, low detail",
|
| 264 |
+
image=pipeline_image,
|
| 265 |
+
mask_image=pipeline_mask,
|
| 266 |
+
num_inference_steps=args.steps,
|
| 267 |
+
guidance_scale=args.guidance_scale,
|
| 268 |
+
width=512,
|
| 269 |
+
height=512,
|
| 270 |
+
generator=generator,
|
| 271 |
+
)
|
| 272 |
+
generated = response.images[0]
|
| 273 |
+
if crop_bounds is not None:
|
| 274 |
+
generated = generated.resize(
|
| 275 |
+
(crop_bounds[2] - crop_bounds[0], crop_bounds[3] - crop_bounds[1]),
|
| 276 |
+
Image.Resampling.LANCZOS,
|
| 277 |
+
)
|
| 278 |
+
full_generated = source.copy()
|
| 279 |
+
full_generated.paste(generated, crop_bounds[:2])
|
| 280 |
+
generated = full_generated
|
| 281 |
+
result = _composite(generated, source, mask_image)
|
| 282 |
+
result.save(output_dir / f"{index:02d}-{case['name']}.png")
|
| 283 |
+
results.append(result)
|
| 284 |
+
_release_cuda(pipeline)
|
| 285 |
+
return results
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _crop_around_mask(image: Image.Image, mask: Image.Image, padding: int = 48) -> Image.Image:
|
| 289 |
+
bounds = mask.getbbox()
|
| 290 |
+
if bounds is None:
|
| 291 |
+
return image
|
| 292 |
+
left, top, right, bottom = bounds
|
| 293 |
+
return image.crop(
|
| 294 |
+
(
|
| 295 |
+
max(0, left - padding),
|
| 296 |
+
max(0, top - padding),
|
| 297 |
+
min(image.width, right + padding),
|
| 298 |
+
min(image.height, bottom + padding),
|
| 299 |
+
)
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _image_metrics(
|
| 304 |
+
image: Image.Image,
|
| 305 |
+
source: Image.Image,
|
| 306 |
+
mask: Image.Image,
|
| 307 |
+
) -> dict[str, float | int]:
|
| 308 |
+
image_array = np.asarray(image.convert("RGB"), dtype=np.int16)
|
| 309 |
+
source_array = np.asarray(source.convert("RGB"), dtype=np.int16)
|
| 310 |
+
selected = np.asarray(mask.convert("L")) >= 128
|
| 311 |
+
changed = np.any(image_array != source_array, axis=2)
|
| 312 |
+
black = np.all(image_array <= 8, axis=2)
|
| 313 |
+
absolute_change = np.abs(image_array - source_array).mean(axis=2) / 255.0
|
| 314 |
+
eroded = np.asarray(
|
| 315 |
+
mask.convert("L").filter(ImageFilter.MinFilter(size=17))
|
| 316 |
+
) >= 128
|
| 317 |
+
inner_boundary = selected & ~eroded
|
| 318 |
+
return {
|
| 319 |
+
"mask_area_fraction": float(selected.mean()),
|
| 320 |
+
"masked_change_fraction": float(changed[selected].mean()),
|
| 321 |
+
"masked_mean_absolute_change": float(absolute_change[selected].mean()),
|
| 322 |
+
"boundary_mean_absolute_change": float(
|
| 323 |
+
absolute_change[inner_boundary].mean()
|
| 324 |
+
),
|
| 325 |
+
"masked_black_fraction": float(black[selected].mean()),
|
| 326 |
+
"outside_changed_pixels": int(changed[~selected].sum()),
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def _clip_scores(
|
| 331 |
+
*,
|
| 332 |
+
model_name: str,
|
| 333 |
+
revision: str | None,
|
| 334 |
+
cases: list[dict[str, Any]],
|
| 335 |
+
outputs: dict[str, list[Image.Image]],
|
| 336 |
+
) -> tuple[dict[str, list[float]], dict[str, list[float]]]:
|
| 337 |
+
kwargs = {"revision": revision} if revision else {}
|
| 338 |
+
processor = CLIPProcessor.from_pretrained(model_name, **kwargs)
|
| 339 |
+
model = CLIPModel.from_pretrained(model_name, **kwargs).to("cuda")
|
| 340 |
+
scores: dict[str, list[float]] = {}
|
| 341 |
+
for label, images in outputs.items():
|
| 342 |
+
label_scores = []
|
| 343 |
+
for case, image in zip(cases, images):
|
| 344 |
+
inputs = processor(
|
| 345 |
+
text=[case["score_prompt"]],
|
| 346 |
+
images=[_crop_around_mask(image, case["mask_image"])],
|
| 347 |
+
return_tensors="pt",
|
| 348 |
+
padding=True,
|
| 349 |
+
).to("cuda")
|
| 350 |
+
with torch.inference_mode():
|
| 351 |
+
vision = model.get_image_features(pixel_values=inputs["pixel_values"])
|
| 352 |
+
text = model.get_text_features(
|
| 353 |
+
input_ids=inputs["input_ids"],
|
| 354 |
+
attention_mask=inputs["attention_mask"],
|
| 355 |
+
)
|
| 356 |
+
vision = vision / vision.norm(dim=-1, keepdim=True)
|
| 357 |
+
text = text / text.norm(dim=-1, keepdim=True)
|
| 358 |
+
label_scores.append(float((vision @ text.T).item()))
|
| 359 |
+
scores[label] = label_scores
|
| 360 |
+
teacher_scores = {label: [] for label in outputs}
|
| 361 |
+
for index, case in enumerate(cases):
|
| 362 |
+
labels = list(outputs)
|
| 363 |
+
crops = [
|
| 364 |
+
_crop_around_mask(outputs[label][index], case["mask_image"])
|
| 365 |
+
for label in labels
|
| 366 |
+
]
|
| 367 |
+
inputs = processor(images=crops, return_tensors="pt").to("cuda")
|
| 368 |
+
with torch.inference_mode():
|
| 369 |
+
features = model.get_image_features(pixel_values=inputs["pixel_values"])
|
| 370 |
+
features = features / features.norm(dim=-1, keepdim=True)
|
| 371 |
+
teacher_index = labels.index("teacher")
|
| 372 |
+
similarities = features @ features[teacher_index]
|
| 373 |
+
for label, similarity in zip(labels, similarities):
|
| 374 |
+
teacher_scores[label].append(float(similarity.item()))
|
| 375 |
+
_release_cuda(model)
|
| 376 |
+
return scores, teacher_scores
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def _make_sheet(
|
| 380 |
+
cases: list[dict[str, Any]],
|
| 381 |
+
outputs: dict[str, list[Image.Image]],
|
| 382 |
+
destination: Path,
|
| 383 |
+
) -> None:
|
| 384 |
+
labels = ["source + mask", "current", "teacher", "candidate"]
|
| 385 |
+
cell = 512
|
| 386 |
+
label_height = 34
|
| 387 |
+
sheet = Image.new("RGB", (cell * len(labels), (cell + label_height) * len(cases)), "white")
|
| 388 |
+
draw = ImageDraw.Draw(sheet)
|
| 389 |
+
for index, case in enumerate(cases):
|
| 390 |
+
row_y = index * (cell + label_height)
|
| 391 |
+
mask_overlay = Image.new("RGB", case["source"].size, (255, 255, 255))
|
| 392 |
+
source_mask = Image.blend(
|
| 393 |
+
case["source"],
|
| 394 |
+
Image.composite(mask_overlay, case["source"], case["mask_image"]),
|
| 395 |
+
0.55,
|
| 396 |
+
)
|
| 397 |
+
images = [source_mask, outputs["baseline"][index], outputs["teacher"][index], outputs["candidate"][index]]
|
| 398 |
+
for column, (label, image) in enumerate(zip(labels, images)):
|
| 399 |
+
sheet.paste(image, (column * cell, row_y + label_height))
|
| 400 |
+
draw.text((column * cell + 8, row_y + 9), label, fill="black")
|
| 401 |
+
draw.text((cell + 88, row_y + 9), f"{case['name']}: {case['edit_prompt'][:52]}", fill="black")
|
| 402 |
+
sheet.save(destination)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def main() -> None:
|
| 406 |
+
args = parse_args()
|
| 407 |
+
args.output_dir.mkdir(parents=True, exist_ok=False)
|
| 408 |
+
cases = [dict(case) for case in SOURCE_CASES]
|
| 409 |
+
_generate_sources(args, cases)
|
| 410 |
+
|
| 411 |
+
outputs: dict[str, list[Image.Image]] = {}
|
| 412 |
+
model_specs = {
|
| 413 |
+
"baseline": (args.baseline_model, args.baseline_revision, None),
|
| 414 |
+
"teacher": (
|
| 415 |
+
args.teacher_model,
|
| 416 |
+
args.teacher_revision,
|
| 417 |
+
args.teacher_variant,
|
| 418 |
+
),
|
| 419 |
+
"candidate": (args.candidate_model, None, None),
|
| 420 |
+
}
|
| 421 |
+
for label, (model_name, revision, variant) in model_specs.items():
|
| 422 |
+
output_dir = args.output_dir / label
|
| 423 |
+
output_dir.mkdir()
|
| 424 |
+
outputs[label] = _run_inpainting_model(
|
| 425 |
+
model_name=model_name,
|
| 426 |
+
revision=revision,
|
| 427 |
+
variant=variant,
|
| 428 |
+
cases=cases,
|
| 429 |
+
args=args,
|
| 430 |
+
output_dir=output_dir,
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
clip_scores, teacher_image_scores = _clip_scores(
|
| 434 |
+
model_name=args.clip_model,
|
| 435 |
+
revision=args.clip_revision,
|
| 436 |
+
cases=cases,
|
| 437 |
+
outputs=outputs,
|
| 438 |
+
)
|
| 439 |
+
records = []
|
| 440 |
+
for index, case in enumerate(cases):
|
| 441 |
+
record: dict[str, Any] = {
|
| 442 |
+
"index": index,
|
| 443 |
+
"name": case["name"],
|
| 444 |
+
"source_prompt": case["source_prompt"],
|
| 445 |
+
"edit_prompt": case["edit_prompt"],
|
| 446 |
+
"score_prompt": case["score_prompt"],
|
| 447 |
+
}
|
| 448 |
+
for label, images in outputs.items():
|
| 449 |
+
record[label] = {
|
| 450 |
+
**_image_metrics(images[index], case["source"], case["mask_image"]),
|
| 451 |
+
"clip_similarity": clip_scores[label][index],
|
| 452 |
+
"clip_similarity_to_teacher": teacher_image_scores[label][index],
|
| 453 |
+
}
|
| 454 |
+
records.append(record)
|
| 455 |
+
|
| 456 |
+
summary = {
|
| 457 |
+
"base_model": args.base_model,
|
| 458 |
+
"base_revision": args.base_revision,
|
| 459 |
+
"baseline_model": args.baseline_model,
|
| 460 |
+
"baseline_revision": args.baseline_revision,
|
| 461 |
+
"teacher_model": args.teacher_model,
|
| 462 |
+
"teacher_revision": args.teacher_revision,
|
| 463 |
+
"teacher_variant": args.teacher_variant,
|
| 464 |
+
"candidate_model": args.candidate_model,
|
| 465 |
+
"clip_model": args.clip_model,
|
| 466 |
+
"clip_revision": args.clip_revision,
|
| 467 |
+
"steps": args.steps,
|
| 468 |
+
"guidance_scale": args.guidance_scale,
|
| 469 |
+
"mask_crop_padding": args.mask_crop_padding,
|
| 470 |
+
"seed": args.seed,
|
| 471 |
+
"cases": records,
|
| 472 |
+
"mean_clip_similarity": {
|
| 473 |
+
label: float(np.mean(scores)) for label, scores in clip_scores.items()
|
| 474 |
+
},
|
| 475 |
+
"mean_clip_similarity_to_teacher": {
|
| 476 |
+
label: float(np.mean(scores))
|
| 477 |
+
for label, scores in teacher_image_scores.items()
|
| 478 |
+
},
|
| 479 |
+
"candidate_clip_wins_over_baseline": int(
|
| 480 |
+
sum(
|
| 481 |
+
candidate > baseline
|
| 482 |
+
for candidate, baseline in zip(
|
| 483 |
+
clip_scores["candidate"], clip_scores["baseline"]
|
| 484 |
+
)
|
| 485 |
+
)
|
| 486 |
+
),
|
| 487 |
+
}
|
| 488 |
+
(args.output_dir / "metrics.json").write_text(json.dumps(summary, indent=2) + "\n")
|
| 489 |
+
_make_sheet(cases, outputs, args.output_dir / "comparison.png")
|
| 490 |
+
print(json.dumps(summary, indent=2))
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
if __name__ == "__main__":
|
| 494 |
+
main()
|
inpainting/masks.py
CHANGED
|
@@ -1,57 +1,195 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import random
|
| 6 |
|
| 7 |
-
from PIL import Image, ImageChops, ImageDraw
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
size: tuple[int, int],
|
| 12 |
rng: random.Random,
|
| 13 |
*,
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
width, height = size
|
| 20 |
mask = Image.new("L", size, 0)
|
| 21 |
draw = ImageDraw.Draw(mask)
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
)
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def apply_mask(image: Image.Image, mask: Image.Image) -> Image.Image:
|
|
@@ -60,3 +198,9 @@ def apply_mask(image: Image.Image, mask: Image.Image) -> Image.Image:
|
|
| 60 |
image = image.convert("RGB")
|
| 61 |
keep = ImageChops.invert(mask.convert("L"))
|
| 62 |
return Image.composite(image, Image.new("RGB", image.size), keep)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Diverse synthetic masks used for context-aware inpainting training."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import math
|
| 6 |
import random
|
| 7 |
|
| 8 |
+
from PIL import Image, ImageChops, ImageDraw, ImageFilter
|
| 9 |
|
| 10 |
+
MASK_KINDS = (
|
| 11 |
+
"brush",
|
| 12 |
+
"multi_brush",
|
| 13 |
+
"rectangle",
|
| 14 |
+
"ellipse",
|
| 15 |
+
"polygon",
|
| 16 |
+
"multi_region",
|
| 17 |
+
"outpaint",
|
| 18 |
+
)
|
| 19 |
|
| 20 |
+
|
| 21 |
+
def _area_fraction(mask: Image.Image) -> float:
|
| 22 |
+
histogram = mask.convert("L").histogram()
|
| 23 |
+
white_sum = sum(value * count for value, count in enumerate(histogram))
|
| 24 |
+
return white_sum / (255.0 * mask.width * mask.height)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _random_box(
|
| 28 |
+
size: tuple[int, int],
|
| 29 |
+
rng: random.Random,
|
| 30 |
+
target_area: float,
|
| 31 |
+
) -> tuple[int, int, int, int]:
|
| 32 |
+
width, height = size
|
| 33 |
+
aspect = math.exp(rng.uniform(math.log(0.35), math.log(2.85)))
|
| 34 |
+
box_width = min(width, max(8, round(math.sqrt(target_area * aspect))))
|
| 35 |
+
box_height = min(height, max(8, round(math.sqrt(target_area / aspect))))
|
| 36 |
+
left = rng.randint(0, max(0, width - box_width))
|
| 37 |
+
top = rng.randint(0, max(0, height - box_height))
|
| 38 |
+
return left, top, left + box_width, top + box_height
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _draw_brush(
|
| 42 |
+
draw: ImageDraw.ImageDraw,
|
| 43 |
size: tuple[int, int],
|
| 44 |
rng: random.Random,
|
| 45 |
*,
|
| 46 |
+
strokes: int,
|
| 47 |
+
) -> None:
|
| 48 |
+
width, height = size
|
| 49 |
+
for _ in range(strokes):
|
| 50 |
+
stroke_width = max(6, round(min(width, height) * rng.uniform(0.035, 0.16)))
|
| 51 |
+
point_count = rng.randint(3, 8)
|
| 52 |
+
x = rng.uniform(0, width - 1)
|
| 53 |
+
y = rng.uniform(0, height - 1)
|
| 54 |
+
points: list[tuple[float, float]] = [(x, y)]
|
| 55 |
+
angle = rng.uniform(0, 2 * math.pi)
|
| 56 |
+
for _ in range(point_count - 1):
|
| 57 |
+
angle += rng.uniform(-1.25, 1.25)
|
| 58 |
+
distance = rng.uniform(0.06, 0.24) * min(width, height)
|
| 59 |
+
x = min(width - 1, max(0, x + math.cos(angle) * distance))
|
| 60 |
+
y = min(height - 1, max(0, y + math.sin(angle) * distance))
|
| 61 |
+
points.append((x, y))
|
| 62 |
+
draw.line(points, fill=255, width=stroke_width, joint="curve")
|
| 63 |
+
radius = stroke_width / 2
|
| 64 |
+
for px, py in points:
|
| 65 |
+
draw.ellipse((px - radius, py - radius, px + radius, py + radius), fill=255)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _draw_outpaint(
|
| 69 |
+
draw: ImageDraw.ImageDraw,
|
| 70 |
+
size: tuple[int, int],
|
| 71 |
+
rng: random.Random,
|
| 72 |
+
target_fraction: float,
|
| 73 |
+
) -> None:
|
| 74 |
+
width, height = size
|
| 75 |
+
sides = rng.sample(("left", "right", "top", "bottom"), rng.choice((1, 1, 1, 2)))
|
| 76 |
+
remaining = max(0.02, target_fraction)
|
| 77 |
+
for index, side in enumerate(sides):
|
| 78 |
+
share = remaining if index == len(sides) - 1 else remaining * rng.uniform(0.35, 0.7)
|
| 79 |
+
if side in {"left", "right"}:
|
| 80 |
+
thickness = max(4, min(width, round(share * width)))
|
| 81 |
+
box = (0, 0, thickness, height) if side == "left" else (width - thickness, 0, width, height)
|
| 82 |
+
else:
|
| 83 |
+
thickness = max(4, min(height, round(share * height)))
|
| 84 |
+
box = (0, 0, width, thickness) if side == "top" else (0, height - thickness, width, height)
|
| 85 |
+
draw.rectangle(box, fill=255)
|
| 86 |
+
remaining = max(0.0, remaining - share)
|
| 87 |
+
|
| 88 |
|
| 89 |
+
def _draw_candidate(
|
| 90 |
+
size: tuple[int, int],
|
| 91 |
+
rng: random.Random,
|
| 92 |
+
*,
|
| 93 |
+
kind: str,
|
| 94 |
+
target_fraction: float,
|
| 95 |
+
) -> Image.Image:
|
| 96 |
width, height = size
|
| 97 |
mask = Image.new("L", size, 0)
|
| 98 |
draw = ImageDraw.Draw(mask)
|
| 99 |
+
target_area = target_fraction * width * height
|
| 100 |
+
|
| 101 |
+
if kind == "rectangle":
|
| 102 |
+
draw.rounded_rectangle(
|
| 103 |
+
_random_box(size, rng, target_area),
|
| 104 |
+
radius=rng.randint(0, max(1, round(min(width, height) * 0.08))),
|
| 105 |
+
fill=255,
|
| 106 |
+
)
|
| 107 |
+
elif kind == "ellipse":
|
| 108 |
+
draw.ellipse(_random_box(size, rng, target_area), fill=255)
|
| 109 |
+
elif kind == "polygon":
|
| 110 |
+
center_x = rng.uniform(width * 0.2, width * 0.8)
|
| 111 |
+
center_y = rng.uniform(height * 0.2, height * 0.8)
|
| 112 |
+
radius = math.sqrt(target_area / math.pi)
|
| 113 |
+
points = []
|
| 114 |
+
point_count = rng.randint(5, 10)
|
| 115 |
+
for index in range(point_count):
|
| 116 |
+
angle = (2 * math.pi * index / point_count) + rng.uniform(-0.25, 0.25)
|
| 117 |
+
local_radius = radius * rng.uniform(0.65, 1.35)
|
| 118 |
+
points.append(
|
| 119 |
+
(
|
| 120 |
+
min(width - 1, max(0, center_x + math.cos(angle) * local_radius)),
|
| 121 |
+
min(height - 1, max(0, center_y + math.sin(angle) * local_radius)),
|
| 122 |
+
)
|
| 123 |
)
|
| 124 |
+
draw.polygon(points, fill=255)
|
| 125 |
+
elif kind == "brush":
|
| 126 |
+
_draw_brush(draw, size, rng, strokes=1)
|
| 127 |
+
elif kind == "multi_brush":
|
| 128 |
+
_draw_brush(draw, size, rng, strokes=rng.randint(2, 5))
|
| 129 |
+
elif kind == "multi_region":
|
| 130 |
+
region_count = rng.randint(2, 5)
|
| 131 |
+
for _ in range(region_count):
|
| 132 |
+
region_area = target_area * rng.uniform(0.12, 0.5)
|
| 133 |
+
box = _random_box(size, rng, region_area)
|
| 134 |
+
if rng.random() < 0.55:
|
| 135 |
+
draw.ellipse(box, fill=255)
|
| 136 |
+
else:
|
| 137 |
+
draw.rounded_rectangle(box, radius=rng.randint(2, 24), fill=255)
|
| 138 |
+
elif kind == "outpaint":
|
| 139 |
+
_draw_outpaint(draw, size, rng, target_fraction)
|
| 140 |
+
else:
|
| 141 |
+
raise ValueError(f"Unsupported mask kind: {kind}")
|
| 142 |
|
| 143 |
+
if kind not in {"rectangle", "outpaint"} and rng.random() < 0.35:
|
| 144 |
+
# A small close operation removes pinholes without making every edge
|
| 145 |
+
# unnaturally geometric.
|
| 146 |
+
mask = mask.filter(ImageFilter.MaxFilter(rng.choice((3, 5, 7))))
|
| 147 |
+
return mask.point(lambda value: 255 if value >= 128 else 0, mode="L")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def random_mask(
|
| 151 |
+
size: tuple[int, int],
|
| 152 |
+
rng: random.Random,
|
| 153 |
+
*,
|
| 154 |
+
min_area: float = 0.04,
|
| 155 |
+
max_area: float = 0.65,
|
| 156 |
+
kind: str | None = None,
|
| 157 |
+
) -> Image.Image:
|
| 158 |
+
"""Return a binary mask where white means regenerate.
|
| 159 |
+
|
| 160 |
+
The sampler deliberately mixes object-like regions, free-form user brush
|
| 161 |
+
strokes, disconnected edits, and edge/outpainting masks. Candidates are
|
| 162 |
+
retried so the actual white area—not only a geometric estimate—falls close
|
| 163 |
+
to the requested range.
|
| 164 |
+
"""
|
| 165 |
+
|
| 166 |
+
if not 0.0 < min_area < max_area < 1.0:
|
| 167 |
+
raise ValueError("mask area bounds must satisfy 0 < min < max < 1")
|
| 168 |
+
if kind is not None and kind not in MASK_KINDS:
|
| 169 |
+
raise ValueError(f"Unsupported mask kind: {kind}")
|
| 170 |
+
|
| 171 |
+
best_mask: Image.Image | None = None
|
| 172 |
+
best_distance = float("inf")
|
| 173 |
+
target = rng.uniform(min_area, max_area)
|
| 174 |
+
for _ in range(16):
|
| 175 |
+
selected_kind = kind or rng.choices(
|
| 176 |
+
MASK_KINDS,
|
| 177 |
+
weights=(24, 18, 12, 10, 12, 16, 8),
|
| 178 |
+
k=1,
|
| 179 |
+
)[0]
|
| 180 |
+
candidate = _draw_candidate(size, rng, kind=selected_kind, target_fraction=target)
|
| 181 |
+
area = _area_fraction(candidate)
|
| 182 |
+
if min_area <= area <= max_area:
|
| 183 |
+
return candidate
|
| 184 |
+
distance = min(abs(area - min_area), abs(area - max_area))
|
| 185 |
+
if distance < best_distance:
|
| 186 |
+
best_mask = candidate
|
| 187 |
+
best_distance = distance
|
| 188 |
+
target = rng.uniform(min_area, max_area)
|
| 189 |
+
|
| 190 |
+
if best_mask is None:
|
| 191 |
+
raise RuntimeError("mask generation did not produce a candidate")
|
| 192 |
+
return best_mask
|
| 193 |
|
| 194 |
|
| 195 |
def apply_mask(image: Image.Image, mask: Image.Image) -> Image.Image:
|
|
|
|
| 198 |
image = image.convert("RGB")
|
| 199 |
keep = ImageChops.invert(mask.convert("L"))
|
| 200 |
return Image.composite(image, Image.new("RGB", image.size), keep)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def mask_area_fraction(mask: Image.Image) -> float:
|
| 204 |
+
"""Expose the exact white-area fraction for validation and reporting."""
|
| 205 |
+
|
| 206 |
+
return _area_fraction(mask)
|
inpainting/objective.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Loss helpers for context-aware inpainting distillation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def min_snr_weights(
|
| 10 |
+
alphas_cumprod: torch.Tensor,
|
| 11 |
+
timesteps: torch.Tensor,
|
| 12 |
+
*,
|
| 13 |
+
gamma: float,
|
| 14 |
+
prediction_type: str,
|
| 15 |
+
) -> torch.Tensor:
|
| 16 |
+
"""Return the Min-SNR weighting from diffusion fine-tuning literature."""
|
| 17 |
+
|
| 18 |
+
alpha = alphas_cumprod.to(device=timesteps.device, dtype=torch.float32)[timesteps]
|
| 19 |
+
snr = alpha / (1.0 - alpha).clamp_min(1e-8)
|
| 20 |
+
clipped = torch.minimum(snr, torch.full_like(snr, gamma))
|
| 21 |
+
if prediction_type == "epsilon":
|
| 22 |
+
return clipped / snr.clamp_min(1e-8)
|
| 23 |
+
if prediction_type == "v_prediction":
|
| 24 |
+
return clipped / (snr + 1.0)
|
| 25 |
+
raise ValueError(f"Unsupported prediction type: {prediction_type}")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def spatial_loss_weights(
|
| 29 |
+
mask: torch.Tensor,
|
| 30 |
+
*,
|
| 31 |
+
context_weight: float,
|
| 32 |
+
masked_weight: float,
|
| 33 |
+
boundary_weight: float,
|
| 34 |
+
boundary_radius: int = 2,
|
| 35 |
+
) -> torch.Tensor:
|
| 36 |
+
"""Emphasize regenerated pixels and the context-sensitive mask boundary."""
|
| 37 |
+
|
| 38 |
+
if mask.ndim != 4 or mask.shape[1] != 1:
|
| 39 |
+
raise ValueError(f"Expected BCHW single-channel mask, got {tuple(mask.shape)}")
|
| 40 |
+
if boundary_radius < 1:
|
| 41 |
+
raise ValueError("boundary_radius must be positive")
|
| 42 |
+
binary = (mask >= 0.5).to(dtype=torch.float32)
|
| 43 |
+
kernel = boundary_radius * 2 + 1
|
| 44 |
+
dilated = F.max_pool2d(binary, kernel_size=kernel, stride=1, padding=boundary_radius)
|
| 45 |
+
eroded = 1.0 - F.max_pool2d(
|
| 46 |
+
1.0 - binary,
|
| 47 |
+
kernel_size=kernel,
|
| 48 |
+
stride=1,
|
| 49 |
+
padding=boundary_radius,
|
| 50 |
+
)
|
| 51 |
+
boundary = (dilated - eroded).clamp(0.0, 1.0)
|
| 52 |
+
weights = torch.full_like(binary, float(context_weight))
|
| 53 |
+
weights = weights + binary * (float(masked_weight) - float(context_weight))
|
| 54 |
+
weights = weights + boundary * float(boundary_weight)
|
| 55 |
+
return weights / weights.mean(dim=(1, 2, 3), keepdim=True).clamp_min(1e-8)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def weighted_mse(
|
| 59 |
+
prediction: torch.Tensor,
|
| 60 |
+
target: torch.Tensor,
|
| 61 |
+
*,
|
| 62 |
+
spatial_weights: torch.Tensor,
|
| 63 |
+
sample_weights: torch.Tensor,
|
| 64 |
+
) -> torch.Tensor:
|
| 65 |
+
"""Compute channel-averaged, spatially and per-sample weighted MSE."""
|
| 66 |
+
|
| 67 |
+
if prediction.shape != target.shape:
|
| 68 |
+
raise ValueError("prediction and target must have identical shapes")
|
| 69 |
+
per_pixel = (prediction.float() - target.float()).square().mean(dim=1, keepdim=True)
|
| 70 |
+
per_sample = (per_pixel * spatial_weights.float()).mean(dim=(1, 2, 3))
|
| 71 |
+
return (per_sample * sample_weights.float()).mean()
|
inpainting/test_v2.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
import unittest
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
from PIL import Image
|
| 9 |
+
|
| 10 |
+
from inpainting.masks import MASK_KINDS, apply_mask, mask_area_fraction, random_mask
|
| 11 |
+
from inpainting.objective import min_snr_weights, spatial_loss_weights, weighted_mse
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class MaskTests(unittest.TestCase):
|
| 15 |
+
def test_every_mask_family_is_binary_and_nonempty(self) -> None:
|
| 16 |
+
for index, kind in enumerate(MASK_KINDS):
|
| 17 |
+
mask = random_mask(
|
| 18 |
+
(256, 256),
|
| 19 |
+
random.Random(1000 + index),
|
| 20 |
+
min_area=0.03,
|
| 21 |
+
max_area=0.70,
|
| 22 |
+
kind=kind,
|
| 23 |
+
)
|
| 24 |
+
values = set(np.unique(np.asarray(mask)).tolist())
|
| 25 |
+
self.assertTrue(values <= {0, 255}, (kind, values))
|
| 26 |
+
area = mask_area_fraction(mask)
|
| 27 |
+
self.assertGreater(area, 0.005, kind)
|
| 28 |
+
self.assertLess(area, 0.90, kind)
|
| 29 |
+
|
| 30 |
+
def test_apply_mask_only_blacks_white_region(self) -> None:
|
| 31 |
+
image = Image.new("RGB", (8, 8), (20, 40, 60))
|
| 32 |
+
mask = Image.new("L", (8, 8), 0)
|
| 33 |
+
for y in range(2, 6):
|
| 34 |
+
for x in range(3, 7):
|
| 35 |
+
mask.putpixel((x, y), 255)
|
| 36 |
+
result = np.asarray(apply_mask(image, mask))
|
| 37 |
+
self.assertTrue(np.all(result[2:6, 3:7] == 0))
|
| 38 |
+
self.assertTrue(np.all(result[0, 0] == (20, 40, 60)))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class ObjectiveTests(unittest.TestCase):
|
| 42 |
+
def test_spatial_weights_prioritize_mask_and_boundary(self) -> None:
|
| 43 |
+
mask = torch.zeros(1, 1, 16, 16)
|
| 44 |
+
mask[:, :, 5:11, 5:11] = 1
|
| 45 |
+
weights = spatial_loss_weights(
|
| 46 |
+
mask,
|
| 47 |
+
context_weight=0.25,
|
| 48 |
+
masked_weight=2.5,
|
| 49 |
+
boundary_weight=2.0,
|
| 50 |
+
boundary_radius=1,
|
| 51 |
+
)
|
| 52 |
+
self.assertAlmostEqual(weights.mean().item(), 1.0, places=5)
|
| 53 |
+
self.assertGreater(weights[0, 0, 5, 5], weights[0, 0, 8, 8])
|
| 54 |
+
self.assertGreater(weights[0, 0, 8, 8], weights[0, 0, 0, 0])
|
| 55 |
+
|
| 56 |
+
def test_min_snr_and_weighted_mse_are_finite(self) -> None:
|
| 57 |
+
alphas = torch.linspace(0.999, 0.001, 1000)
|
| 58 |
+
timesteps = torch.tensor([0, 250, 999])
|
| 59 |
+
sample_weights = min_snr_weights(
|
| 60 |
+
alphas,
|
| 61 |
+
timesteps,
|
| 62 |
+
gamma=5.0,
|
| 63 |
+
prediction_type="epsilon",
|
| 64 |
+
)
|
| 65 |
+
prediction = torch.ones(3, 4, 8, 8)
|
| 66 |
+
target = torch.zeros_like(prediction)
|
| 67 |
+
spatial = torch.ones(3, 1, 8, 8)
|
| 68 |
+
loss = weighted_mse(
|
| 69 |
+
prediction,
|
| 70 |
+
target,
|
| 71 |
+
spatial_weights=spatial,
|
| 72 |
+
sample_weights=sample_weights,
|
| 73 |
+
)
|
| 74 |
+
self.assertTrue(torch.isfinite(loss))
|
| 75 |
+
self.assertGreater(loss.item(), 0)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
unittest.main()
|
inpainting/train.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
|
| 4 |
-
The
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
@@ -14,6 +16,7 @@ import shutil
|
|
| 14 |
from pathlib import Path
|
| 15 |
from typing import Any
|
| 16 |
|
|
|
|
| 17 |
import torch
|
| 18 |
import torch.nn.functional as F
|
| 19 |
from accelerate import Accelerator
|
|
@@ -22,20 +25,25 @@ from diffusers import (
|
|
| 22 |
AutoencoderKL,
|
| 23 |
DDPMScheduler,
|
| 24 |
StableDiffusionInpaintPipeline,
|
|
|
|
| 25 |
get_scheduler,
|
| 26 |
)
|
| 27 |
-
from PIL import Image
|
| 28 |
from transformers import CLIPTextModel, CLIPTokenizer
|
| 29 |
-
from torchvision import transforms
|
| 30 |
|
| 31 |
-
from inpainting.masks import apply_mask, random_mask
|
| 32 |
from inpainting.model import make_inpainting_unet
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def parse_args() -> argparse.Namespace:
|
| 36 |
parser = argparse.ArgumentParser()
|
| 37 |
parser.add_argument("--pretrained_model_name_or_path", required=True)
|
| 38 |
parser.add_argument("--revision")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
parser.add_argument("--dataset_name", required=True)
|
| 40 |
parser.add_argument("--dataset_config_name")
|
| 41 |
parser.add_argument("--dataset_revision")
|
|
@@ -44,19 +52,32 @@ def parse_args() -> argparse.Namespace:
|
|
| 44 |
parser.add_argument("--caption_column", default="caption")
|
| 45 |
parser.add_argument("--resolution", type=int, default=512)
|
| 46 |
parser.add_argument("--train_batch_size", type=int, default=1)
|
| 47 |
-
parser.add_argument("--max_train_steps", type=int, default=
|
| 48 |
-
parser.add_argument("--learning_rate", type=float, default=
|
| 49 |
parser.add_argument("--lr_scheduler", default="cosine")
|
| 50 |
-
parser.add_argument("--lr_warmup_steps", type=int, default=
|
| 51 |
parser.add_argument("--gradient_accumulation_steps", type=int, default=4)
|
| 52 |
parser.add_argument("--gradient_checkpointing", action="store_true")
|
| 53 |
-
parser.add_argument("--mixed_precision", choices=("no", "fp16", "bf16"), default="
|
| 54 |
-
parser.add_argument("--seed", type=int, default=
|
| 55 |
-
parser.add_argument("--mask_min_area", type=float, default=0.
|
| 56 |
-
parser.add_argument("--mask_max_area", type=float, default=0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
parser.add_argument("--max_train_samples", type=int)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
parser.add_argument("--output_dir", type=Path, required=True)
|
| 59 |
-
parser.add_argument("--validation_prompt")
|
| 60 |
parser.add_argument("--push_to_hub", action="store_true")
|
| 61 |
parser.add_argument("--hub_model_id")
|
| 62 |
return parser.parse_args()
|
|
@@ -66,16 +87,43 @@ def model_kwargs(revision: str | None) -> dict[str, Any]:
|
|
| 66 |
return {"revision": revision} if revision else {}
|
| 67 |
|
| 68 |
|
| 69 |
-
def
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
def make_collate_fn(
|
|
@@ -85,25 +133,34 @@ def make_collate_fn(
|
|
| 85 |
min_area: float,
|
| 86 |
max_area: float,
|
| 87 |
seed: int,
|
|
|
|
|
|
|
| 88 |
):
|
| 89 |
-
worker_rng
|
| 90 |
|
| 91 |
def collate(examples: list[dict[str, Any]]) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
images: list[torch.Tensor] = []
|
| 93 |
masked_images: list[torch.Tensor] = []
|
| 94 |
masks: list[torch.Tensor] = []
|
| 95 |
captions: list[str] = []
|
|
|
|
| 96 |
|
| 97 |
for example in examples:
|
| 98 |
image = example["image"]
|
| 99 |
if not isinstance(image, Image.Image):
|
| 100 |
-
image = Image.fromarray(image)
|
| 101 |
-
image =
|
| 102 |
-
|
| 103 |
-
resolution,
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
mask = random_mask(
|
| 108 |
(resolution, resolution),
|
| 109 |
worker_rng,
|
|
@@ -112,16 +169,16 @@ def make_collate_fn(
|
|
| 112 |
)
|
| 113 |
masked = apply_mask(image, mask)
|
| 114 |
|
| 115 |
-
images.append(
|
| 116 |
-
masked_images.append(
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
)
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
|
| 126 |
tokenized = tokenizer(
|
| 127 |
captions,
|
|
@@ -135,32 +192,56 @@ def make_collate_fn(
|
|
| 135 |
"masked_pixel_values": torch.stack(masked_images),
|
| 136 |
"mask": torch.stack(masks),
|
| 137 |
"input_ids": tokenized.input_ids,
|
|
|
|
| 138 |
}
|
| 139 |
|
| 140 |
return collate
|
| 141 |
|
| 142 |
|
| 143 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
*,
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
unet,
|
| 148 |
output_dir: Path,
|
| 149 |
) -> None:
|
| 150 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 151 |
pipeline = StableDiffusionInpaintPipeline.from_pretrained(
|
| 152 |
-
|
| 153 |
-
revision=revision,
|
| 154 |
unet=unet,
|
| 155 |
)
|
| 156 |
pipeline.save_pretrained(output_dir, safe_serialization=True)
|
| 157 |
(output_dir / "inpainting-config.json").write_text(
|
| 158 |
json.dumps(
|
| 159 |
{
|
|
|
|
| 160 |
"unet_in_channels": 9,
|
| 161 |
"mask_semantics": "white=regenerate, black=preserve",
|
| 162 |
-
"base_model":
|
| 163 |
-
"base_revision": revision,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
},
|
| 165 |
indent=2,
|
| 166 |
)
|
|
@@ -168,28 +249,60 @@ def save_pipeline(
|
|
| 168 |
)
|
| 169 |
|
| 170 |
|
| 171 |
-
def
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
if not 0.0 < args.mask_min_area < args.mask_max_area < 1.0:
|
| 174 |
raise ValueError("mask area bounds must satisfy 0 < min < max < 1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
if args.push_to_hub and not args.hub_model_id:
|
| 176 |
raise ValueError("--hub_model_id is required with --push_to_hub")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
accelerator = Accelerator(
|
| 179 |
gradient_accumulation_steps=args.gradient_accumulation_steps,
|
| 180 |
mixed_precision=args.mixed_precision,
|
| 181 |
)
|
| 182 |
-
accelerator.init_trackers("clover-image-tiny-inpaint")
|
| 183 |
torch.manual_seed(args.seed)
|
| 184 |
random.seed(args.seed)
|
|
|
|
| 185 |
|
| 186 |
kwargs = model_kwargs(args.revision)
|
|
|
|
| 187 |
dataset = load_dataset(
|
| 188 |
args.dataset_name,
|
| 189 |
args.dataset_config_name,
|
| 190 |
split=args.dataset_split,
|
| 191 |
revision=args.dataset_revision,
|
| 192 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
if args.max_train_samples:
|
| 194 |
dataset = dataset.select(range(min(args.max_train_samples, len(dataset))))
|
| 195 |
if args.image_column not in dataset.column_names:
|
|
@@ -208,29 +321,41 @@ def main() -> None:
|
|
| 208 |
text_encoder = CLIPTextModel.from_pretrained(
|
| 209 |
args.pretrained_model_name_or_path,
|
| 210 |
subfolder="text_encoder",
|
|
|
|
| 211 |
**kwargs,
|
| 212 |
)
|
| 213 |
vae = AutoencoderKL.from_pretrained(
|
| 214 |
args.pretrained_model_name_or_path,
|
| 215 |
subfolder="vae",
|
|
|
|
| 216 |
**kwargs,
|
| 217 |
)
|
| 218 |
-
|
| 219 |
-
args.pretrained_model_name_or_path,
|
| 220 |
-
revision=args.revision,
|
| 221 |
-
)
|
| 222 |
noise_scheduler = DDPMScheduler.from_pretrained(
|
| 223 |
args.pretrained_model_name_or_path,
|
| 224 |
subfolder="scheduler",
|
| 225 |
**kwargs,
|
| 226 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
|
| 228 |
if args.gradient_checkpointing:
|
| 229 |
-
|
| 230 |
-
text_encoder.requires_grad_(False)
|
| 231 |
-
vae.requires_grad_(False)
|
| 232 |
-
|
| 233 |
-
|
| 234 |
|
| 235 |
collate_fn = make_collate_fn(
|
| 236 |
tokenizer=tokenizer,
|
|
@@ -238,17 +363,20 @@ def main() -> None:
|
|
| 238 |
min_area=args.mask_min_area,
|
| 239 |
max_area=args.mask_max_area,
|
| 240 |
seed=args.seed,
|
|
|
|
|
|
|
| 241 |
)
|
| 242 |
dataloader = torch.utils.data.DataLoader(
|
| 243 |
dataset,
|
| 244 |
shuffle=True,
|
| 245 |
collate_fn=collate_fn,
|
| 246 |
batch_size=args.train_batch_size,
|
| 247 |
-
num_workers=
|
| 248 |
pin_memory=True,
|
|
|
|
| 249 |
)
|
| 250 |
optimizer = torch.optim.AdamW(
|
| 251 |
-
|
| 252 |
lr=args.learning_rate,
|
| 253 |
betas=(0.9, 0.999),
|
| 254 |
weight_decay=1e-2,
|
|
@@ -261,18 +389,42 @@ def main() -> None:
|
|
| 261 |
num_training_steps=args.max_train_steps * accelerator.num_processes,
|
| 262 |
)
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
| 266 |
)
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
weight_dtype = torch.bfloat16
|
| 272 |
-
vae.to(accelerator.device, dtype=weight_dtype)
|
| 273 |
-
text_encoder.to(accelerator.device, dtype=weight_dtype)
|
| 274 |
|
| 275 |
global_step = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
data_iterator = iter(dataloader)
|
| 277 |
while global_step < args.max_train_steps:
|
| 278 |
try:
|
|
@@ -281,7 +433,7 @@ def main() -> None:
|
|
| 281 |
data_iterator = iter(dataloader)
|
| 282 |
batch = next(data_iterator)
|
| 283 |
|
| 284 |
-
with accelerator.accumulate(
|
| 285 |
pixel_values = batch["pixel_values"].to(
|
| 286 |
accelerator.device, dtype=weight_dtype, non_blocking=True
|
| 287 |
)
|
|
@@ -296,7 +448,9 @@ def main() -> None:
|
|
| 296 |
latents = latents * vae.config.scaling_factor
|
| 297 |
masked_latents = vae.encode(masked_pixel_values).latent_dist.sample()
|
| 298 |
masked_latents = masked_latents * vae.config.scaling_factor
|
| 299 |
-
encoder_hidden_states = text_encoder(
|
|
|
|
|
|
|
| 300 |
|
| 301 |
noise = torch.randn_like(latents)
|
| 302 |
timesteps = torch.randint(
|
|
@@ -306,61 +460,117 @@ def main() -> None:
|
|
| 306 |
device=latents.device,
|
| 307 |
).long()
|
| 308 |
noisy_latents = noise_scheduler.add_noise(latents, noise, timesteps)
|
| 309 |
-
|
| 310 |
-
model_input = torch.cat([noisy_latents,
|
| 311 |
-
model_pred =
|
| 312 |
model_input,
|
| 313 |
timesteps,
|
| 314 |
encoder_hidden_states=encoder_hidden_states,
|
| 315 |
).sample
|
| 316 |
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
| 321 |
else:
|
| 322 |
-
raise ValueError(
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
)
|
| 325 |
-
loss =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
accelerator.backward(loss)
|
| 327 |
if accelerator.sync_gradients:
|
| 328 |
-
accelerator.clip_grad_norm_(
|
| 329 |
optimizer.step()
|
| 330 |
lr_scheduler.step()
|
| 331 |
optimizer.zero_grad(set_to_none=True)
|
| 332 |
|
| 333 |
if accelerator.sync_gradients:
|
| 334 |
global_step += 1
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
accelerator.print(
|
| 337 |
f"step={global_step}/{args.max_train_steps} "
|
| 338 |
-
f"loss={loss
|
| 339 |
-
f"
|
|
|
|
|
|
|
|
|
|
| 340 |
)
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
|
| 346 |
accelerator.wait_for_everyone()
|
| 347 |
if accelerator.is_main_process:
|
| 348 |
-
unwrapped = accelerator.unwrap_model(
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
json.dumps(summary, indent=2, default=str) + "\n"
|
| 359 |
)
|
| 360 |
if args.push_to_hub:
|
| 361 |
pipeline = StableDiffusionInpaintPipeline.from_pretrained(args.output_dir)
|
| 362 |
pipeline.push_to_hub(args.hub_model_id)
|
| 363 |
-
accelerator.print(f"Saved Clover Image Tiny Inpaint to {args.output_dir}")
|
| 364 |
accelerator.end_training()
|
| 365 |
|
| 366 |
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""Train Clover Image Tiny v2 as a context-aware 9-channel inpainting model.
|
| 3 |
|
| 4 |
+
The trainer warm-starts from the existing Clover inpainting checkpoint and
|
| 5 |
+
distills the official SD 1.5 inpainting U-Net while retaining a ground-truth
|
| 6 |
+
diffusion objective. Masks are synthesized on the fly with free-form,
|
| 7 |
+
multi-region, object-like, and outpainting geometry.
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
|
|
|
| 16 |
from pathlib import Path
|
| 17 |
from typing import Any
|
| 18 |
|
| 19 |
+
import numpy as np
|
| 20 |
import torch
|
| 21 |
import torch.nn.functional as F
|
| 22 |
from accelerate import Accelerator
|
|
|
|
| 25 |
AutoencoderKL,
|
| 26 |
DDPMScheduler,
|
| 27 |
StableDiffusionInpaintPipeline,
|
| 28 |
+
UNet2DConditionModel,
|
| 29 |
get_scheduler,
|
| 30 |
)
|
| 31 |
+
from PIL import Image, ImageOps
|
| 32 |
from transformers import CLIPTextModel, CLIPTokenizer
|
|
|
|
| 33 |
|
| 34 |
+
from inpainting.masks import apply_mask, mask_area_fraction, random_mask
|
| 35 |
from inpainting.model import make_inpainting_unet
|
| 36 |
+
from inpainting.objective import min_snr_weights, spatial_loss_weights, weighted_mse
|
| 37 |
|
| 38 |
|
| 39 |
def parse_args() -> argparse.Namespace:
|
| 40 |
parser = argparse.ArgumentParser()
|
| 41 |
parser.add_argument("--pretrained_model_name_or_path", required=True)
|
| 42 |
parser.add_argument("--revision")
|
| 43 |
+
parser.add_argument("--initial_inpaint_model")
|
| 44 |
+
parser.add_argument("--initial_inpaint_revision")
|
| 45 |
+
parser.add_argument("--teacher_model_name_or_path")
|
| 46 |
+
parser.add_argument("--teacher_revision")
|
| 47 |
parser.add_argument("--dataset_name", required=True)
|
| 48 |
parser.add_argument("--dataset_config_name")
|
| 49 |
parser.add_argument("--dataset_revision")
|
|
|
|
| 52 |
parser.add_argument("--caption_column", default="caption")
|
| 53 |
parser.add_argument("--resolution", type=int, default=512)
|
| 54 |
parser.add_argument("--train_batch_size", type=int, default=1)
|
| 55 |
+
parser.add_argument("--max_train_steps", type=int, default=12000)
|
| 56 |
+
parser.add_argument("--learning_rate", type=float, default=5e-6)
|
| 57 |
parser.add_argument("--lr_scheduler", default="cosine")
|
| 58 |
+
parser.add_argument("--lr_warmup_steps", type=int, default=500)
|
| 59 |
parser.add_argument("--gradient_accumulation_steps", type=int, default=4)
|
| 60 |
parser.add_argument("--gradient_checkpointing", action="store_true")
|
| 61 |
+
parser.add_argument("--mixed_precision", choices=("no", "fp16", "bf16"), default="bf16")
|
| 62 |
+
parser.add_argument("--seed", type=int, default=20260811)
|
| 63 |
+
parser.add_argument("--mask_min_area", type=float, default=0.04)
|
| 64 |
+
parser.add_argument("--mask_max_area", type=float, default=0.65)
|
| 65 |
+
parser.add_argument("--caption_dropout_probability", type=float, default=0.10)
|
| 66 |
+
parser.add_argument("--teacher_loss_weight", type=float, default=0.75)
|
| 67 |
+
parser.add_argument("--ground_truth_loss_weight", type=float, default=0.25)
|
| 68 |
+
parser.add_argument("--context_loss_weight", type=float, default=0.25)
|
| 69 |
+
parser.add_argument("--masked_loss_weight", type=float, default=2.5)
|
| 70 |
+
parser.add_argument("--boundary_loss_weight", type=float, default=2.0)
|
| 71 |
+
parser.add_argument("--boundary_radius", type=int, default=2)
|
| 72 |
+
parser.add_argument("--snr_gamma", type=float, default=5.0)
|
| 73 |
+
parser.add_argument("--random_flip", action="store_true")
|
| 74 |
parser.add_argument("--max_train_samples", type=int)
|
| 75 |
+
parser.add_argument("--validation_samples", type=int, default=128)
|
| 76 |
+
parser.add_argument("--num_workers", type=int, default=4)
|
| 77 |
+
parser.add_argument("--checkpointing_steps", type=int, default=500)
|
| 78 |
+
parser.add_argument("--checkpoints_total_limit", type=int, default=3)
|
| 79 |
+
parser.add_argument("--resume_from_checkpoint")
|
| 80 |
parser.add_argument("--output_dir", type=Path, required=True)
|
|
|
|
| 81 |
parser.add_argument("--push_to_hub", action="store_true")
|
| 82 |
parser.add_argument("--hub_model_id")
|
| 83 |
return parser.parse_args()
|
|
|
|
| 87 |
return {"revision": revision} if revision else {}
|
| 88 |
|
| 89 |
|
| 90 |
+
def _frozen_weight_dtype(mixed_precision: str) -> torch.dtype:
|
| 91 |
+
if mixed_precision == "fp16":
|
| 92 |
+
return torch.float16
|
| 93 |
+
if mixed_precision == "bf16":
|
| 94 |
+
return torch.bfloat16
|
| 95 |
+
return torch.float32
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _normalize_caption(value: Any) -> str:
|
| 99 |
+
if isinstance(value, list):
|
| 100 |
+
value = value[0] if value else ""
|
| 101 |
+
return " ".join(str(value or "").split())
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _crop_image(
|
| 105 |
+
image: Image.Image,
|
| 106 |
+
*,
|
| 107 |
+
resolution: int,
|
| 108 |
+
rng: random.Random,
|
| 109 |
+
random_flip: bool,
|
| 110 |
+
) -> Image.Image:
|
| 111 |
+
image = ImageOps.exif_transpose(image).convert("RGB")
|
| 112 |
+
width, height = image.size
|
| 113 |
+
scale = rng.uniform(0.78, 1.0)
|
| 114 |
+
crop_side = max(1, round(min(width, height) * scale))
|
| 115 |
+
left = rng.randint(0, max(0, width - crop_side))
|
| 116 |
+
top = rng.randint(0, max(0, height - crop_side))
|
| 117 |
+
image = image.crop((left, top, left + crop_side, top + crop_side))
|
| 118 |
+
image = image.resize((resolution, resolution), Image.Resampling.LANCZOS)
|
| 119 |
+
if random_flip and rng.random() < 0.5:
|
| 120 |
+
image = ImageOps.mirror(image)
|
| 121 |
+
return image
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _image_to_tensor(image: Image.Image) -> torch.Tensor:
|
| 125 |
+
array = np.asarray(image.convert("RGB"), dtype=np.float32) / 127.5 - 1.0
|
| 126 |
+
return torch.from_numpy(array).permute(2, 0, 1).contiguous()
|
| 127 |
|
| 128 |
|
| 129 |
def make_collate_fn(
|
|
|
|
| 133 |
min_area: float,
|
| 134 |
max_area: float,
|
| 135 |
seed: int,
|
| 136 |
+
caption_dropout_probability: float,
|
| 137 |
+
random_flip: bool,
|
| 138 |
):
|
| 139 |
+
worker_rng: random.Random | None = None
|
| 140 |
|
| 141 |
def collate(examples: list[dict[str, Any]]) -> dict[str, Any]:
|
| 142 |
+
nonlocal worker_rng
|
| 143 |
+
if worker_rng is None:
|
| 144 |
+
# torch.initial_seed differs across DataLoader workers and remains
|
| 145 |
+
# stable for the lifetime of each worker process.
|
| 146 |
+
worker_rng = random.Random(seed ^ torch.initial_seed())
|
| 147 |
+
|
| 148 |
images: list[torch.Tensor] = []
|
| 149 |
masked_images: list[torch.Tensor] = []
|
| 150 |
masks: list[torch.Tensor] = []
|
| 151 |
captions: list[str] = []
|
| 152 |
+
mask_areas: list[float] = []
|
| 153 |
|
| 154 |
for example in examples:
|
| 155 |
image = example["image"]
|
| 156 |
if not isinstance(image, Image.Image):
|
| 157 |
+
image = Image.fromarray(np.asarray(image))
|
| 158 |
+
image = _crop_image(
|
| 159 |
+
image,
|
| 160 |
+
resolution=resolution,
|
| 161 |
+
rng=worker_rng,
|
| 162 |
+
random_flip=random_flip,
|
| 163 |
+
)
|
| 164 |
mask = random_mask(
|
| 165 |
(resolution, resolution),
|
| 166 |
worker_rng,
|
|
|
|
| 169 |
)
|
| 170 |
masked = apply_mask(image, mask)
|
| 171 |
|
| 172 |
+
images.append(_image_to_tensor(image))
|
| 173 |
+
masked_images.append(_image_to_tensor(masked))
|
| 174 |
+
masks.append(
|
| 175 |
+
torch.from_numpy(np.asarray(mask, dtype=np.float32) / 255.0).unsqueeze(0)
|
| 176 |
+
)
|
| 177 |
+
caption = _normalize_caption(example["caption"])
|
| 178 |
+
if worker_rng.random() < caption_dropout_probability:
|
| 179 |
+
caption = ""
|
| 180 |
+
captions.append(caption)
|
| 181 |
+
mask_areas.append(mask_area_fraction(mask))
|
| 182 |
|
| 183 |
tokenized = tokenizer(
|
| 184 |
captions,
|
|
|
|
| 192 |
"masked_pixel_values": torch.stack(masked_images),
|
| 193 |
"mask": torch.stack(masks),
|
| 194 |
"input_ids": tokenized.input_ids,
|
| 195 |
+
"mask_area": torch.tensor(mask_areas, dtype=torch.float32),
|
| 196 |
}
|
| 197 |
|
| 198 |
return collate
|
| 199 |
|
| 200 |
|
| 201 |
+
def _load_student(args: argparse.Namespace) -> UNet2DConditionModel:
|
| 202 |
+
if args.initial_inpaint_model:
|
| 203 |
+
student = UNet2DConditionModel.from_pretrained(
|
| 204 |
+
args.initial_inpaint_model,
|
| 205 |
+
subfolder="unet",
|
| 206 |
+
low_cpu_mem_usage=False,
|
| 207 |
+
**model_kwargs(args.initial_inpaint_revision),
|
| 208 |
+
)
|
| 209 |
+
if student.config.in_channels != 9:
|
| 210 |
+
raise ValueError("initial inpainting U-Net must have nine input channels")
|
| 211 |
+
return student
|
| 212 |
+
return make_inpainting_unet(
|
| 213 |
+
args.pretrained_model_name_or_path,
|
| 214 |
+
revision=args.revision,
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _save_pipeline(
|
| 219 |
*,
|
| 220 |
+
args: argparse.Namespace,
|
| 221 |
+
unet: UNet2DConditionModel,
|
|
|
|
| 222 |
output_dir: Path,
|
| 223 |
) -> None:
|
| 224 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 225 |
pipeline = StableDiffusionInpaintPipeline.from_pretrained(
|
| 226 |
+
args.pretrained_model_name_or_path,
|
| 227 |
+
revision=args.revision,
|
| 228 |
unet=unet,
|
| 229 |
)
|
| 230 |
pipeline.save_pretrained(output_dir, safe_serialization=True)
|
| 231 |
(output_dir / "inpainting-config.json").write_text(
|
| 232 |
json.dumps(
|
| 233 |
{
|
| 234 |
+
"recipe_version": 2,
|
| 235 |
"unet_in_channels": 9,
|
| 236 |
"mask_semantics": "white=regenerate, black=preserve",
|
| 237 |
+
"base_model": args.pretrained_model_name_or_path,
|
| 238 |
+
"base_revision": args.revision,
|
| 239 |
+
"initial_inpaint_model": args.initial_inpaint_model,
|
| 240 |
+
"initial_inpaint_revision": args.initial_inpaint_revision,
|
| 241 |
+
"teacher_model": args.teacher_model_name_or_path,
|
| 242 |
+
"teacher_revision": args.teacher_revision,
|
| 243 |
+
"objective": "teacher distillation + ground-truth diffusion",
|
| 244 |
+
"mask_distribution": "brush, multi-brush, box, ellipse, polygon, multi-region, outpaint",
|
| 245 |
},
|
| 246 |
indent=2,
|
| 247 |
)
|
|
|
|
| 249 |
)
|
| 250 |
|
| 251 |
|
| 252 |
+
def _prune_checkpoints(checkpoint_root: Path, limit: int) -> None:
|
| 253 |
+
checkpoints = sorted(
|
| 254 |
+
(path for path in checkpoint_root.glob("checkpoint-*") if path.is_dir()),
|
| 255 |
+
key=lambda path: int(path.name.rsplit("-", 1)[-1]),
|
| 256 |
+
)
|
| 257 |
+
for path in checkpoints[:-limit]:
|
| 258 |
+
shutil.rmtree(path)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def _validate_args(args: argparse.Namespace) -> None:
|
| 262 |
if not 0.0 < args.mask_min_area < args.mask_max_area < 1.0:
|
| 263 |
raise ValueError("mask area bounds must satisfy 0 < min < max < 1")
|
| 264 |
+
if not 0.0 <= args.caption_dropout_probability < 1.0:
|
| 265 |
+
raise ValueError("caption dropout probability must be in [0, 1)")
|
| 266 |
+
if args.teacher_loss_weight < 0 or args.ground_truth_loss_weight < 0:
|
| 267 |
+
raise ValueError("loss weights must be non-negative")
|
| 268 |
+
if args.teacher_loss_weight > 0 and not args.teacher_model_name_or_path:
|
| 269 |
+
raise ValueError("teacher model is required when teacher loss is enabled")
|
| 270 |
+
if args.teacher_loss_weight + args.ground_truth_loss_weight <= 0:
|
| 271 |
+
raise ValueError("at least one training objective must be enabled")
|
| 272 |
if args.push_to_hub and not args.hub_model_id:
|
| 273 |
raise ValueError("--hub_model_id is required with --push_to_hub")
|
| 274 |
+
if args.resolution % 8:
|
| 275 |
+
raise ValueError("resolution must be divisible by 8")
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def main() -> None:
|
| 279 |
+
args = parse_args()
|
| 280 |
+
_validate_args(args)
|
| 281 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 282 |
+
checkpoint_root = args.output_dir / "checkpoints"
|
| 283 |
+
metrics_path = args.output_dir / "training-metrics.jsonl"
|
| 284 |
|
| 285 |
accelerator = Accelerator(
|
| 286 |
gradient_accumulation_steps=args.gradient_accumulation_steps,
|
| 287 |
mixed_precision=args.mixed_precision,
|
| 288 |
)
|
|
|
|
| 289 |
torch.manual_seed(args.seed)
|
| 290 |
random.seed(args.seed)
|
| 291 |
+
np.random.seed(args.seed % (2**32))
|
| 292 |
|
| 293 |
kwargs = model_kwargs(args.revision)
|
| 294 |
+
weight_dtype = _frozen_weight_dtype(args.mixed_precision)
|
| 295 |
dataset = load_dataset(
|
| 296 |
args.dataset_name,
|
| 297 |
args.dataset_config_name,
|
| 298 |
split=args.dataset_split,
|
| 299 |
revision=args.dataset_revision,
|
| 300 |
)
|
| 301 |
+
dataset = dataset.shuffle(seed=args.seed)
|
| 302 |
+
if args.validation_samples < 0 or args.validation_samples >= len(dataset):
|
| 303 |
+
raise ValueError("validation_samples must be smaller than the dataset")
|
| 304 |
+
if args.validation_samples:
|
| 305 |
+
dataset = dataset.select(range(len(dataset) - args.validation_samples))
|
| 306 |
if args.max_train_samples:
|
| 307 |
dataset = dataset.select(range(min(args.max_train_samples, len(dataset))))
|
| 308 |
if args.image_column not in dataset.column_names:
|
|
|
|
| 321 |
text_encoder = CLIPTextModel.from_pretrained(
|
| 322 |
args.pretrained_model_name_or_path,
|
| 323 |
subfolder="text_encoder",
|
| 324 |
+
dtype=weight_dtype,
|
| 325 |
**kwargs,
|
| 326 |
)
|
| 327 |
vae = AutoencoderKL.from_pretrained(
|
| 328 |
args.pretrained_model_name_or_path,
|
| 329 |
subfolder="vae",
|
| 330 |
+
torch_dtype=weight_dtype,
|
| 331 |
**kwargs,
|
| 332 |
)
|
| 333 |
+
student = _load_student(args)
|
|
|
|
|
|
|
|
|
|
| 334 |
noise_scheduler = DDPMScheduler.from_pretrained(
|
| 335 |
args.pretrained_model_name_or_path,
|
| 336 |
subfolder="scheduler",
|
| 337 |
**kwargs,
|
| 338 |
)
|
| 339 |
+
teacher = None
|
| 340 |
+
if args.teacher_loss_weight > 0:
|
| 341 |
+
teacher = UNet2DConditionModel.from_pretrained(
|
| 342 |
+
args.teacher_model_name_or_path,
|
| 343 |
+
subfolder="unet",
|
| 344 |
+
low_cpu_mem_usage=True,
|
| 345 |
+
torch_dtype=weight_dtype,
|
| 346 |
+
use_safetensors=True,
|
| 347 |
+
variant="fp16",
|
| 348 |
+
**model_kwargs(args.teacher_revision),
|
| 349 |
+
)
|
| 350 |
+
if teacher.config.in_channels != 9:
|
| 351 |
+
raise ValueError("teacher inpainting U-Net must have nine input channels")
|
| 352 |
|
| 353 |
if args.gradient_checkpointing:
|
| 354 |
+
student.enable_gradient_checkpointing()
|
| 355 |
+
text_encoder.requires_grad_(False).eval()
|
| 356 |
+
vae.requires_grad_(False).eval()
|
| 357 |
+
if teacher is not None:
|
| 358 |
+
teacher.requires_grad_(False).eval()
|
| 359 |
|
| 360 |
collate_fn = make_collate_fn(
|
| 361 |
tokenizer=tokenizer,
|
|
|
|
| 363 |
min_area=args.mask_min_area,
|
| 364 |
max_area=args.mask_max_area,
|
| 365 |
seed=args.seed,
|
| 366 |
+
caption_dropout_probability=args.caption_dropout_probability,
|
| 367 |
+
random_flip=args.random_flip,
|
| 368 |
)
|
| 369 |
dataloader = torch.utils.data.DataLoader(
|
| 370 |
dataset,
|
| 371 |
shuffle=True,
|
| 372 |
collate_fn=collate_fn,
|
| 373 |
batch_size=args.train_batch_size,
|
| 374 |
+
num_workers=args.num_workers,
|
| 375 |
pin_memory=True,
|
| 376 |
+
persistent_workers=args.num_workers > 0,
|
| 377 |
)
|
| 378 |
optimizer = torch.optim.AdamW(
|
| 379 |
+
student.parameters(),
|
| 380 |
lr=args.learning_rate,
|
| 381 |
betas=(0.9, 0.999),
|
| 382 |
weight_decay=1e-2,
|
|
|
|
| 389 |
num_training_steps=args.max_train_steps * accelerator.num_processes,
|
| 390 |
)
|
| 391 |
|
| 392 |
+
student, optimizer, dataloader, lr_scheduler = accelerator.prepare(
|
| 393 |
+
student, optimizer, dataloader, lr_scheduler
|
| 394 |
)
|
| 395 |
+
vae.to(accelerator.device)
|
| 396 |
+
text_encoder.to(accelerator.device)
|
| 397 |
+
if teacher is not None:
|
| 398 |
+
teacher.to(accelerator.device)
|
|
|
|
|
|
|
|
|
|
| 399 |
|
| 400 |
global_step = 0
|
| 401 |
+
if args.resume_from_checkpoint:
|
| 402 |
+
checkpoint = Path(args.resume_from_checkpoint)
|
| 403 |
+
if args.resume_from_checkpoint == "latest":
|
| 404 |
+
candidates = sorted(
|
| 405 |
+
checkpoint_root.glob("checkpoint-*"),
|
| 406 |
+
key=lambda path: int(path.name.rsplit("-", 1)[-1]),
|
| 407 |
+
)
|
| 408 |
+
if not candidates:
|
| 409 |
+
raise ValueError("No checkpoint is available to resume")
|
| 410 |
+
checkpoint = candidates[-1]
|
| 411 |
+
accelerator.load_state(checkpoint)
|
| 412 |
+
global_step = int(checkpoint.name.rsplit("-", 1)[-1])
|
| 413 |
+
accelerator.print(f"Resumed from {checkpoint} at step {global_step}")
|
| 414 |
+
|
| 415 |
+
if accelerator.is_main_process:
|
| 416 |
+
summary = vars(args).copy()
|
| 417 |
+
summary["output_dir"] = str(args.output_dir)
|
| 418 |
+
summary["dataset_size"] = len(dataset)
|
| 419 |
+
summary["effective_batch_size"] = (
|
| 420 |
+
args.train_batch_size
|
| 421 |
+
* args.gradient_accumulation_steps
|
| 422 |
+
* accelerator.num_processes
|
| 423 |
+
)
|
| 424 |
+
(args.output_dir / "training-summary.json").write_text(
|
| 425 |
+
json.dumps(summary, indent=2, default=str) + "\n"
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
data_iterator = iter(dataloader)
|
| 429 |
while global_step < args.max_train_steps:
|
| 430 |
try:
|
|
|
|
| 433 |
data_iterator = iter(dataloader)
|
| 434 |
batch = next(data_iterator)
|
| 435 |
|
| 436 |
+
with accelerator.accumulate(student):
|
| 437 |
pixel_values = batch["pixel_values"].to(
|
| 438 |
accelerator.device, dtype=weight_dtype, non_blocking=True
|
| 439 |
)
|
|
|
|
| 448 |
latents = latents * vae.config.scaling_factor
|
| 449 |
masked_latents = vae.encode(masked_pixel_values).latent_dist.sample()
|
| 450 |
masked_latents = masked_latents * vae.config.scaling_factor
|
| 451 |
+
encoder_hidden_states = text_encoder(
|
| 452 |
+
batch["input_ids"].to(accelerator.device)
|
| 453 |
+
)[0]
|
| 454 |
|
| 455 |
noise = torch.randn_like(latents)
|
| 456 |
timesteps = torch.randint(
|
|
|
|
| 460 |
device=latents.device,
|
| 461 |
).long()
|
| 462 |
noisy_latents = noise_scheduler.add_noise(latents, noise, timesteps)
|
| 463 |
+
latent_mask = F.interpolate(mask, size=latents.shape[-2:], mode="nearest")
|
| 464 |
+
model_input = torch.cat([noisy_latents, latent_mask, masked_latents], dim=1)
|
| 465 |
+
model_pred = student(
|
| 466 |
model_input,
|
| 467 |
timesteps,
|
| 468 |
encoder_hidden_states=encoder_hidden_states,
|
| 469 |
).sample
|
| 470 |
|
| 471 |
+
prediction_type = noise_scheduler.config.prediction_type
|
| 472 |
+
if prediction_type == "epsilon":
|
| 473 |
+
ground_truth_target = noise
|
| 474 |
+
elif prediction_type == "v_prediction":
|
| 475 |
+
ground_truth_target = noise_scheduler.get_velocity(latents, noise, timesteps)
|
| 476 |
else:
|
| 477 |
+
raise ValueError(f"Unsupported prediction type: {prediction_type}")
|
| 478 |
+
|
| 479 |
+
spatial_weights = spatial_loss_weights(
|
| 480 |
+
latent_mask,
|
| 481 |
+
context_weight=args.context_loss_weight,
|
| 482 |
+
masked_weight=args.masked_loss_weight,
|
| 483 |
+
boundary_weight=args.boundary_loss_weight,
|
| 484 |
+
boundary_radius=args.boundary_radius,
|
| 485 |
+
)
|
| 486 |
+
sample_weights = min_snr_weights(
|
| 487 |
+
noise_scheduler.alphas_cumprod,
|
| 488 |
+
timesteps,
|
| 489 |
+
gamma=args.snr_gamma,
|
| 490 |
+
prediction_type=prediction_type,
|
| 491 |
+
)
|
| 492 |
+
ground_truth_loss = weighted_mse(
|
| 493 |
+
model_pred,
|
| 494 |
+
ground_truth_target,
|
| 495 |
+
spatial_weights=spatial_weights,
|
| 496 |
+
sample_weights=sample_weights,
|
| 497 |
+
)
|
| 498 |
+
teacher_loss = torch.zeros((), device=accelerator.device)
|
| 499 |
+
if teacher is not None:
|
| 500 |
+
with torch.no_grad():
|
| 501 |
+
teacher_target = teacher(
|
| 502 |
+
model_input,
|
| 503 |
+
timesteps,
|
| 504 |
+
encoder_hidden_states=encoder_hidden_states,
|
| 505 |
+
).sample
|
| 506 |
+
teacher_loss = weighted_mse(
|
| 507 |
+
model_pred,
|
| 508 |
+
teacher_target,
|
| 509 |
+
spatial_weights=spatial_weights,
|
| 510 |
+
sample_weights=sample_weights,
|
| 511 |
)
|
| 512 |
+
loss = (
|
| 513 |
+
args.ground_truth_loss_weight * ground_truth_loss
|
| 514 |
+
+ args.teacher_loss_weight * teacher_loss
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
accelerator.backward(loss)
|
| 518 |
if accelerator.sync_gradients:
|
| 519 |
+
accelerator.clip_grad_norm_(student.parameters(), 1.0)
|
| 520 |
optimizer.step()
|
| 521 |
lr_scheduler.step()
|
| 522 |
optimizer.zero_grad(set_to_none=True)
|
| 523 |
|
| 524 |
if accelerator.sync_gradients:
|
| 525 |
global_step += 1
|
| 526 |
+
metrics = {
|
| 527 |
+
"step": global_step,
|
| 528 |
+
"loss": loss.detach().float().item(),
|
| 529 |
+
"teacher_loss": teacher_loss.detach().float().item(),
|
| 530 |
+
"ground_truth_loss": ground_truth_loss.detach().float().item(),
|
| 531 |
+
"lr": lr_scheduler.get_last_lr()[0],
|
| 532 |
+
"mask_area": batch["mask_area"].float().mean().item(),
|
| 533 |
+
}
|
| 534 |
+
if accelerator.is_main_process and (global_step == 1 or global_step % 25 == 0):
|
| 535 |
accelerator.print(
|
| 536 |
f"step={global_step}/{args.max_train_steps} "
|
| 537 |
+
f"loss={metrics['loss']:.5f} "
|
| 538 |
+
f"teacher={metrics['teacher_loss']:.5f} "
|
| 539 |
+
f"ground_truth={metrics['ground_truth_loss']:.5f} "
|
| 540 |
+
f"mask={metrics['mask_area']:.3f} "
|
| 541 |
+
f"lr={metrics['lr']:.3e}"
|
| 542 |
)
|
| 543 |
+
with metrics_path.open("a") as handle:
|
| 544 |
+
handle.write(json.dumps(metrics) + "\n")
|
| 545 |
+
|
| 546 |
+
if global_step % args.checkpointing_steps == 0:
|
| 547 |
+
accelerator.wait_for_everyone()
|
| 548 |
+
checkpoint = checkpoint_root / f"checkpoint-{global_step}"
|
| 549 |
+
accelerator.save_state(checkpoint, safe_serialization=True)
|
| 550 |
+
if accelerator.is_main_process:
|
| 551 |
+
_prune_checkpoints(checkpoint_root, args.checkpoints_total_limit)
|
| 552 |
+
(args.output_dir / "progress.json").write_text(
|
| 553 |
+
json.dumps(metrics, indent=2) + "\n"
|
| 554 |
+
)
|
| 555 |
+
accelerator.print(f"Saved resumable checkpoint {checkpoint}")
|
| 556 |
|
| 557 |
accelerator.wait_for_everyone()
|
| 558 |
if accelerator.is_main_process:
|
| 559 |
+
unwrapped = accelerator.unwrap_model(student).cpu()
|
| 560 |
+
_save_pipeline(args=args, unet=unwrapped, output_dir=args.output_dir)
|
| 561 |
+
final_metrics = {
|
| 562 |
+
"completed_steps": global_step,
|
| 563 |
+
"final_loss": loss.detach().float().item(),
|
| 564 |
+
"final_teacher_loss": teacher_loss.detach().float().item(),
|
| 565 |
+
"final_ground_truth_loss": ground_truth_loss.detach().float().item(),
|
| 566 |
+
}
|
| 567 |
+
(args.output_dir / "training-complete.json").write_text(
|
| 568 |
+
json.dumps(final_metrics, indent=2) + "\n"
|
|
|
|
| 569 |
)
|
| 570 |
if args.push_to_hub:
|
| 571 |
pipeline = StableDiffusionInpaintPipeline.from_pretrained(args.output_dir)
|
| 572 |
pipeline.push_to_hub(args.hub_model_id)
|
| 573 |
+
accelerator.print(f"Saved Clover Image Tiny Inpaint v2 to {args.output_dir}")
|
| 574 |
accelerator.end_training()
|
| 575 |
|
| 576 |
|
modal_inpaint.py
CHANGED
|
@@ -1,24 +1,21 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Run Clover Image Tiny inpainting
|
| 3 |
-
|
| 4 |
-
The default job writes its result to a persistent Modal Volume. This keeps
|
| 5 |
-
training independent from Hub credentials; the trained directory can be
|
| 6 |
-
downloaded and uploaded to the model repository after validation.
|
| 7 |
-
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import os
|
| 12 |
import subprocess
|
| 13 |
import sys
|
|
|
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
import modal
|
| 17 |
|
| 18 |
-
|
| 19 |
-
APP_NAME = "clover-image-tiny-inpaint"
|
| 20 |
OUTPUT_VOLUME_NAME = "clover-image-tiny-inpaint-output"
|
|
|
|
| 21 |
OUTPUT_ROOT = Path("/outputs")
|
|
|
|
| 22 |
|
| 23 |
image = (
|
| 24 |
modal.Image.debian_slim(python_version="3.11")
|
|
@@ -39,42 +36,69 @@ image = (
|
|
| 39 |
)
|
| 40 |
|
| 41 |
output_volume = modal.Volume.from_name(OUTPUT_VOLUME_NAME, create_if_missing=True)
|
|
|
|
| 42 |
app = modal.App(
|
| 43 |
APP_NAME,
|
| 44 |
image=image,
|
| 45 |
-
volumes={
|
|
|
|
|
|
|
|
|
|
| 46 |
)
|
| 47 |
|
| 48 |
|
| 49 |
-
@app.function(gpu="
|
| 50 |
def train(
|
| 51 |
*,
|
| 52 |
base_model: str = "neonforestmist/Clover-Image-Tiny",
|
| 53 |
base_revision: str = "63b0e9f6be9c00888ff464f342a9ef052bf76681",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
dataset: str = "prithivMLmods/Caption3o-Opt",
|
| 55 |
dataset_revision: str = "17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b",
|
| 56 |
dataset_split: str = "train",
|
| 57 |
image_column: str = "image",
|
| 58 |
caption_column: str = "caption",
|
| 59 |
-
max_train_steps: int =
|
| 60 |
-
output_name: str = "clover-image-tiny-inpaint",
|
| 61 |
max_train_samples: int | None = None,
|
| 62 |
-
learning_rate: float =
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
) -> str:
|
| 65 |
-
"""Train
|
| 66 |
|
| 67 |
output_dir = OUTPUT_ROOT / output_name
|
| 68 |
-
if output_dir.exists():
|
| 69 |
-
raise RuntimeError(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
command = [
|
| 72 |
sys.executable,
|
|
|
|
| 73 |
"/root/inpainting/train.py",
|
| 74 |
"--pretrained_model_name_or_path",
|
| 75 |
base_model,
|
| 76 |
"--revision",
|
| 77 |
base_revision,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
"--dataset_name",
|
| 79 |
dataset,
|
| 80 |
"--dataset_revision",
|
|
@@ -89,43 +113,118 @@ def train(
|
|
| 89 |
str(max_train_steps),
|
| 90 |
"--learning_rate",
|
| 91 |
str(learning_rate),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
"--gradient_accumulation_steps",
|
| 93 |
"4",
|
| 94 |
"--gradient_checkpointing",
|
| 95 |
"--mixed_precision",
|
| 96 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
"--seed",
|
| 98 |
str(seed),
|
| 99 |
"--output_dir",
|
| 100 |
str(output_dir),
|
| 101 |
]
|
|
|
|
|
|
|
| 102 |
if max_train_samples is not None:
|
| 103 |
command.extend(["--max_train_samples", str(max_train_samples)])
|
|
|
|
|
|
|
| 104 |
|
| 105 |
env = os.environ.copy()
|
| 106 |
-
env.
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
output_volume.commit()
|
|
|
|
| 109 |
return str(output_dir)
|
| 110 |
|
| 111 |
|
| 112 |
@app.local_entrypoint()
|
| 113 |
def main(
|
| 114 |
smoke: bool = False,
|
| 115 |
-
steps: int =
|
| 116 |
-
output_name: str = "clover-image-tiny-inpaint",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
) -> None:
|
| 118 |
-
"""Launch a
|
| 119 |
|
| 120 |
if smoke:
|
| 121 |
-
steps = min(steps,
|
| 122 |
-
samples =
|
| 123 |
-
output_name = f"{output_name}-smoke"
|
|
|
|
| 124 |
else:
|
| 125 |
samples = None
|
|
|
|
| 126 |
result = train.remote(
|
| 127 |
max_train_steps=steps,
|
| 128 |
max_train_samples=samples,
|
| 129 |
output_name=output_name,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
)
|
| 131 |
print(f"Training output is available in Modal Volume {OUTPUT_VOLUME_NAME}: {result}")
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""Run context-aware Clover Image Tiny inpainting distillation on Modal."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import os
|
| 7 |
import subprocess
|
| 8 |
import sys
|
| 9 |
+
import time
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
import modal
|
| 13 |
|
| 14 |
+
APP_NAME = "clover-image-tiny-inpaint-v2"
|
|
|
|
| 15 |
OUTPUT_VOLUME_NAME = "clover-image-tiny-inpaint-output"
|
| 16 |
+
CACHE_VOLUME_NAME = "clover-image-tiny-inpaint-cache"
|
| 17 |
OUTPUT_ROOT = Path("/outputs")
|
| 18 |
+
CACHE_ROOT = Path("/cache")
|
| 19 |
|
| 20 |
image = (
|
| 21 |
modal.Image.debian_slim(python_version="3.11")
|
|
|
|
| 36 |
)
|
| 37 |
|
| 38 |
output_volume = modal.Volume.from_name(OUTPUT_VOLUME_NAME, create_if_missing=True)
|
| 39 |
+
cache_volume = modal.Volume.from_name(CACHE_VOLUME_NAME, create_if_missing=True)
|
| 40 |
app = modal.App(
|
| 41 |
APP_NAME,
|
| 42 |
image=image,
|
| 43 |
+
volumes={
|
| 44 |
+
str(OUTPUT_ROOT): output_volume,
|
| 45 |
+
str(CACHE_ROOT): cache_volume,
|
| 46 |
+
},
|
| 47 |
)
|
| 48 |
|
| 49 |
|
| 50 |
+
@app.function(gpu="A10", timeout=12 * 60 * 60, cpu=8, memory=32768)
|
| 51 |
def train(
|
| 52 |
*,
|
| 53 |
base_model: str = "neonforestmist/Clover-Image-Tiny",
|
| 54 |
base_revision: str = "63b0e9f6be9c00888ff464f342a9ef052bf76681",
|
| 55 |
+
initial_inpaint_model: str = "neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 56 |
+
initial_inpaint_revision: str = "1b6f8ae3db51900520369d5522c7dc7c2a97e21e",
|
| 57 |
+
teacher_model: str = "stable-diffusion-v1-5/stable-diffusion-inpainting",
|
| 58 |
+
teacher_revision: str = "8a4288a76071f7280aedbdb3253bdb9e9d5d84bb",
|
| 59 |
dataset: str = "prithivMLmods/Caption3o-Opt",
|
| 60 |
dataset_revision: str = "17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b",
|
| 61 |
dataset_split: str = "train",
|
| 62 |
image_column: str = "image",
|
| 63 |
caption_column: str = "caption",
|
| 64 |
+
max_train_steps: int = 12000,
|
| 65 |
+
output_name: str = "clover-image-tiny-inpaint-v2",
|
| 66 |
max_train_samples: int | None = None,
|
| 67 |
+
learning_rate: float = 5e-6,
|
| 68 |
+
lr_warmup_steps: int | None = None,
|
| 69 |
+
teacher_loss_weight: float = 0.75,
|
| 70 |
+
ground_truth_loss_weight: float = 0.25,
|
| 71 |
+
context_loss_weight: float = 0.25,
|
| 72 |
+
masked_loss_weight: float = 2.5,
|
| 73 |
+
boundary_loss_weight: float = 2.0,
|
| 74 |
+
seed: int = 20260811,
|
| 75 |
+
checkpointing_steps: int = 500,
|
| 76 |
+
resume: bool = False,
|
| 77 |
) -> str:
|
| 78 |
+
"""Train v2 and return its persistent Modal Volume path."""
|
| 79 |
|
| 80 |
output_dir = OUTPUT_ROOT / output_name
|
| 81 |
+
if (output_dir / "training-complete.json").exists():
|
| 82 |
+
raise RuntimeError(f"Completed output already exists: {output_dir}")
|
| 83 |
+
if output_dir.exists() and not resume:
|
| 84 |
+
raise RuntimeError(
|
| 85 |
+
f"Partial output already exists; choose another name or enable resume: {output_dir}"
|
| 86 |
+
)
|
| 87 |
|
| 88 |
command = [
|
| 89 |
sys.executable,
|
| 90 |
+
"-u",
|
| 91 |
"/root/inpainting/train.py",
|
| 92 |
"--pretrained_model_name_or_path",
|
| 93 |
base_model,
|
| 94 |
"--revision",
|
| 95 |
base_revision,
|
| 96 |
+
"--initial_inpaint_model",
|
| 97 |
+
initial_inpaint_model,
|
| 98 |
+
"--teacher_model_name_or_path",
|
| 99 |
+
teacher_model,
|
| 100 |
+
"--teacher_revision",
|
| 101 |
+
teacher_revision,
|
| 102 |
"--dataset_name",
|
| 103 |
dataset,
|
| 104 |
"--dataset_revision",
|
|
|
|
| 113 |
str(max_train_steps),
|
| 114 |
"--learning_rate",
|
| 115 |
str(learning_rate),
|
| 116 |
+
"--lr_warmup_steps",
|
| 117 |
+
str(
|
| 118 |
+
lr_warmup_steps
|
| 119 |
+
if lr_warmup_steps is not None
|
| 120 |
+
else min(500, max(1, max_train_steps // 20))
|
| 121 |
+
),
|
| 122 |
"--gradient_accumulation_steps",
|
| 123 |
"4",
|
| 124 |
"--gradient_checkpointing",
|
| 125 |
"--mixed_precision",
|
| 126 |
+
"bf16",
|
| 127 |
+
"--random_flip",
|
| 128 |
+
"--caption_dropout_probability",
|
| 129 |
+
"0.10",
|
| 130 |
+
"--validation_samples",
|
| 131 |
+
"128",
|
| 132 |
+
"--teacher_loss_weight",
|
| 133 |
+
str(teacher_loss_weight),
|
| 134 |
+
"--ground_truth_loss_weight",
|
| 135 |
+
str(ground_truth_loss_weight),
|
| 136 |
+
"--context_loss_weight",
|
| 137 |
+
str(context_loss_weight),
|
| 138 |
+
"--masked_loss_weight",
|
| 139 |
+
str(masked_loss_weight),
|
| 140 |
+
"--boundary_loss_weight",
|
| 141 |
+
str(boundary_loss_weight),
|
| 142 |
+
"--snr_gamma",
|
| 143 |
+
"5.0",
|
| 144 |
+
"--checkpointing_steps",
|
| 145 |
+
str(checkpointing_steps),
|
| 146 |
+
"--checkpoints_total_limit",
|
| 147 |
+
"3",
|
| 148 |
"--seed",
|
| 149 |
str(seed),
|
| 150 |
"--output_dir",
|
| 151 |
str(output_dir),
|
| 152 |
]
|
| 153 |
+
if initial_inpaint_revision:
|
| 154 |
+
command.extend(["--initial_inpaint_revision", initial_inpaint_revision])
|
| 155 |
if max_train_samples is not None:
|
| 156 |
command.extend(["--max_train_samples", str(max_train_samples)])
|
| 157 |
+
if resume:
|
| 158 |
+
command.extend(["--resume_from_checkpoint", "latest"])
|
| 159 |
|
| 160 |
env = os.environ.copy()
|
| 161 |
+
env.update(
|
| 162 |
+
{
|
| 163 |
+
"HF_HOME": str(CACHE_ROOT / "huggingface"),
|
| 164 |
+
"HF_HUB_CACHE": str(CACHE_ROOT / "huggingface" / "hub"),
|
| 165 |
+
"HF_DATASETS_CACHE": str(CACHE_ROOT / "huggingface" / "datasets"),
|
| 166 |
+
"TOKENIZERS_PARALLELISM": "false",
|
| 167 |
+
"PYTHONUNBUFFERED": "1",
|
| 168 |
+
}
|
| 169 |
+
)
|
| 170 |
+
process = subprocess.Popen(command, env=env)
|
| 171 |
+
last_commit = time.monotonic()
|
| 172 |
+
while process.poll() is None:
|
| 173 |
+
time.sleep(30)
|
| 174 |
+
if time.monotonic() - last_commit >= 300:
|
| 175 |
+
output_volume.commit()
|
| 176 |
+
cache_volume.commit()
|
| 177 |
+
last_commit = time.monotonic()
|
| 178 |
+
if process.returncode:
|
| 179 |
+
output_volume.commit()
|
| 180 |
+
cache_volume.commit()
|
| 181 |
+
raise subprocess.CalledProcessError(process.returncode, command)
|
| 182 |
+
|
| 183 |
output_volume.commit()
|
| 184 |
+
cache_volume.commit()
|
| 185 |
return str(output_dir)
|
| 186 |
|
| 187 |
|
| 188 |
@app.local_entrypoint()
|
| 189 |
def main(
|
| 190 |
smoke: bool = False,
|
| 191 |
+
steps: int = 12000,
|
| 192 |
+
output_name: str = "clover-image-tiny-inpaint-v2",
|
| 193 |
+
initial_inpaint_model: str = "neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 194 |
+
initial_inpaint_revision: str | None = "1b6f8ae3db51900520369d5522c7dc7c2a97e21e",
|
| 195 |
+
learning_rate: float = 5e-6,
|
| 196 |
+
lr_warmup_steps: int | None = None,
|
| 197 |
+
teacher_loss_weight: float = 0.75,
|
| 198 |
+
ground_truth_loss_weight: float = 0.25,
|
| 199 |
+
context_loss_weight: float = 0.25,
|
| 200 |
+
masked_loss_weight: float = 2.5,
|
| 201 |
+
boundary_loss_weight: float = 2.0,
|
| 202 |
+
resume: bool = False,
|
| 203 |
) -> None:
|
| 204 |
+
"""Launch a small pipeline test or the full bounded A100 run."""
|
| 205 |
|
| 206 |
if smoke:
|
| 207 |
+
steps = min(steps, 4)
|
| 208 |
+
samples = 8
|
| 209 |
+
output_name = f"{output_name}-smoke-{int(time.time())}"
|
| 210 |
+
checkpointing_steps = 2
|
| 211 |
else:
|
| 212 |
samples = None
|
| 213 |
+
checkpointing_steps = 500
|
| 214 |
result = train.remote(
|
| 215 |
max_train_steps=steps,
|
| 216 |
max_train_samples=samples,
|
| 217 |
output_name=output_name,
|
| 218 |
+
initial_inpaint_model=initial_inpaint_model,
|
| 219 |
+
initial_inpaint_revision=initial_inpaint_revision,
|
| 220 |
+
learning_rate=learning_rate,
|
| 221 |
+
lr_warmup_steps=lr_warmup_steps,
|
| 222 |
+
teacher_loss_weight=teacher_loss_weight,
|
| 223 |
+
ground_truth_loss_weight=ground_truth_loss_weight,
|
| 224 |
+
context_loss_weight=context_loss_weight,
|
| 225 |
+
masked_loss_weight=masked_loss_weight,
|
| 226 |
+
boundary_loss_weight=boundary_loss_weight,
|
| 227 |
+
checkpointing_steps=checkpointing_steps,
|
| 228 |
+
resume=resume,
|
| 229 |
)
|
| 230 |
print(f"Training output is available in Modal Volume {OUTPUT_VOLUME_NAME}: {result}")
|
modal_inpaint_eval.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Evaluate a Modal Volume Clover inpainting candidate against the release."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import modal
|
| 12 |
+
|
| 13 |
+
APP_NAME = "clover-image-tiny-inpaint-eval"
|
| 14 |
+
OUTPUT_VOLUME_NAME = "clover-image-tiny-inpaint-output"
|
| 15 |
+
CACHE_VOLUME_NAME = "clover-image-tiny-inpaint-cache"
|
| 16 |
+
OUTPUT_ROOT = Path("/outputs")
|
| 17 |
+
CACHE_ROOT = Path("/cache")
|
| 18 |
+
|
| 19 |
+
image = (
|
| 20 |
+
modal.Image.debian_slim(python_version="3.11")
|
| 21 |
+
.pip_install(
|
| 22 |
+
"accelerate==1.14.0",
|
| 23 |
+
"datasets==4.8.5",
|
| 24 |
+
"diffusers==0.39.0",
|
| 25 |
+
"huggingface_hub==0.36.0",
|
| 26 |
+
"numpy==2.2.6",
|
| 27 |
+
"pillow==12.3.0",
|
| 28 |
+
"safetensors==0.8.0",
|
| 29 |
+
"torch==2.7.0",
|
| 30 |
+
"torchvision==0.22.0",
|
| 31 |
+
"transformers==4.57.6",
|
| 32 |
+
)
|
| 33 |
+
.add_local_dir("inpainting", remote_path="/root/inpainting")
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
output_volume = modal.Volume.from_name(OUTPUT_VOLUME_NAME, create_if_missing=True)
|
| 37 |
+
cache_volume = modal.Volume.from_name(CACHE_VOLUME_NAME, create_if_missing=True)
|
| 38 |
+
app = modal.App(
|
| 39 |
+
APP_NAME,
|
| 40 |
+
image=image,
|
| 41 |
+
volumes={
|
| 42 |
+
str(OUTPUT_ROOT): output_volume,
|
| 43 |
+
str(CACHE_ROOT): cache_volume,
|
| 44 |
+
},
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@app.function(gpu="A10", timeout=2 * 60 * 60, cpu=4, memory=24576)
|
| 49 |
+
def evaluate(candidate_name: str, evaluation_name: str, sample_count: int = 6) -> str:
|
| 50 |
+
candidate = OUTPUT_ROOT / candidate_name
|
| 51 |
+
if not (candidate / "training-complete.json").exists():
|
| 52 |
+
raise RuntimeError(f"Candidate is not complete: {candidate}")
|
| 53 |
+
destination = OUTPUT_ROOT / "evaluations" / evaluation_name
|
| 54 |
+
if destination.exists():
|
| 55 |
+
raise RuntimeError(f"Evaluation output already exists: {destination}")
|
| 56 |
+
env = os.environ.copy()
|
| 57 |
+
env.update(
|
| 58 |
+
{
|
| 59 |
+
"HF_HOME": str(CACHE_ROOT / "huggingface"),
|
| 60 |
+
"HF_HUB_CACHE": str(CACHE_ROOT / "huggingface" / "hub"),
|
| 61 |
+
"HF_DATASETS_CACHE": str(CACHE_ROOT / "huggingface" / "datasets"),
|
| 62 |
+
"TOKENIZERS_PARALLELISM": "false",
|
| 63 |
+
}
|
| 64 |
+
)
|
| 65 |
+
command = [
|
| 66 |
+
sys.executable,
|
| 67 |
+
"-u",
|
| 68 |
+
"/root/inpainting/evaluate.py",
|
| 69 |
+
"--baseline_model",
|
| 70 |
+
"neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 71 |
+
"--baseline_revision",
|
| 72 |
+
"1b6f8ae3db51900520369d5522c7dc7c2a97e21e",
|
| 73 |
+
"--candidate_model",
|
| 74 |
+
str(candidate),
|
| 75 |
+
"--dataset_name",
|
| 76 |
+
"prithivMLmods/Caption3o-Opt",
|
| 77 |
+
"--dataset_revision",
|
| 78 |
+
"17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b",
|
| 79 |
+
"--sample_count",
|
| 80 |
+
str(sample_count),
|
| 81 |
+
"--output_dir",
|
| 82 |
+
str(destination),
|
| 83 |
+
]
|
| 84 |
+
subprocess.run(command, check=True, env=env)
|
| 85 |
+
output_volume.commit()
|
| 86 |
+
cache_volume.commit()
|
| 87 |
+
return str(destination)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.local_entrypoint()
|
| 91 |
+
def main(candidate_name: str, evaluation_name: str, sample_count: int = 6) -> None:
|
| 92 |
+
result = evaluate.remote(candidate_name, evaluation_name, sample_count)
|
| 93 |
+
print(f"Evaluation is available in Modal Volume {OUTPUT_VOLUME_NAME}: {result}")
|
modal_inpaint_semantic_eval.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Run controlled semantic inpainting evaluation from a Modal Volume candidate."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import modal
|
| 12 |
+
|
| 13 |
+
APP_NAME = "clover-image-tiny-inpaint-semantic-eval"
|
| 14 |
+
OUTPUT_VOLUME_NAME = "clover-image-tiny-inpaint-output"
|
| 15 |
+
CACHE_VOLUME_NAME = "clover-image-tiny-inpaint-cache"
|
| 16 |
+
OUTPUT_ROOT = Path("/outputs")
|
| 17 |
+
CACHE_ROOT = Path("/cache")
|
| 18 |
+
|
| 19 |
+
image = (
|
| 20 |
+
modal.Image.debian_slim(python_version="3.11")
|
| 21 |
+
.pip_install(
|
| 22 |
+
"accelerate==1.14.0",
|
| 23 |
+
"diffusers==0.39.0",
|
| 24 |
+
"numpy==2.2.6",
|
| 25 |
+
"pillow==12.3.0",
|
| 26 |
+
"safetensors==0.8.0",
|
| 27 |
+
"torch==2.7.0",
|
| 28 |
+
"torchvision==0.22.0",
|
| 29 |
+
"transformers==4.57.6",
|
| 30 |
+
)
|
| 31 |
+
.add_local_dir("inpainting", remote_path="/root/inpainting")
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
output_volume = modal.Volume.from_name(OUTPUT_VOLUME_NAME, create_if_missing=True)
|
| 35 |
+
cache_volume = modal.Volume.from_name(CACHE_VOLUME_NAME, create_if_missing=True)
|
| 36 |
+
app = modal.App(
|
| 37 |
+
APP_NAME,
|
| 38 |
+
image=image,
|
| 39 |
+
volumes={
|
| 40 |
+
str(OUTPUT_ROOT): output_volume,
|
| 41 |
+
str(CACHE_ROOT): cache_volume,
|
| 42 |
+
},
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@app.function(gpu="A10", timeout=3 * 60 * 60, cpu=4, memory=24576)
|
| 47 |
+
def evaluate(
|
| 48 |
+
candidate_name: str,
|
| 49 |
+
evaluation_name: str,
|
| 50 |
+
guidance_scale: float = 7.5,
|
| 51 |
+
steps: int = 30,
|
| 52 |
+
mask_crop_padding: int = 0,
|
| 53 |
+
) -> str:
|
| 54 |
+
candidate = OUTPUT_ROOT / candidate_name
|
| 55 |
+
if not (candidate / "training-complete.json").exists():
|
| 56 |
+
raise RuntimeError(f"Candidate is not complete: {candidate}")
|
| 57 |
+
destination = OUTPUT_ROOT / "evaluations" / evaluation_name
|
| 58 |
+
if destination.exists():
|
| 59 |
+
raise RuntimeError(f"Evaluation output already exists: {destination}")
|
| 60 |
+
env = os.environ.copy()
|
| 61 |
+
env.update(
|
| 62 |
+
{
|
| 63 |
+
"HF_HOME": str(CACHE_ROOT / "huggingface"),
|
| 64 |
+
"HF_HUB_CACHE": str(CACHE_ROOT / "huggingface" / "hub"),
|
| 65 |
+
"TOKENIZERS_PARALLELISM": "false",
|
| 66 |
+
}
|
| 67 |
+
)
|
| 68 |
+
command = [
|
| 69 |
+
sys.executable,
|
| 70 |
+
"-u",
|
| 71 |
+
"/root/inpainting/evaluate_semantic.py",
|
| 72 |
+
"--base_model",
|
| 73 |
+
"neonforestmist/Clover-Image-Tiny",
|
| 74 |
+
"--base_revision",
|
| 75 |
+
"63b0e9f6be9c00888ff464f342a9ef052bf76681",
|
| 76 |
+
"--baseline_model",
|
| 77 |
+
"neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 78 |
+
"--baseline_revision",
|
| 79 |
+
"1b6f8ae3db51900520369d5522c7dc7c2a97e21e",
|
| 80 |
+
"--teacher_model",
|
| 81 |
+
"stable-diffusion-v1-5/stable-diffusion-inpainting",
|
| 82 |
+
"--teacher_revision",
|
| 83 |
+
"8a4288a76071f7280aedbdb3253bdb9e9d5d84bb",
|
| 84 |
+
"--teacher_variant",
|
| 85 |
+
"fp16",
|
| 86 |
+
"--candidate_model",
|
| 87 |
+
str(candidate),
|
| 88 |
+
"--clip_model",
|
| 89 |
+
"openai/clip-vit-base-patch32",
|
| 90 |
+
"--clip_revision",
|
| 91 |
+
"3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268",
|
| 92 |
+
"--output_dir",
|
| 93 |
+
str(destination),
|
| 94 |
+
"--guidance_scale",
|
| 95 |
+
str(guidance_scale),
|
| 96 |
+
"--steps",
|
| 97 |
+
str(steps),
|
| 98 |
+
"--mask_crop_padding",
|
| 99 |
+
str(mask_crop_padding),
|
| 100 |
+
]
|
| 101 |
+
subprocess.run(command, check=True, env=env)
|
| 102 |
+
output_volume.commit()
|
| 103 |
+
cache_volume.commit()
|
| 104 |
+
return str(destination)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@app.local_entrypoint()
|
| 108 |
+
def main(
|
| 109 |
+
candidate_name: str,
|
| 110 |
+
evaluation_name: str,
|
| 111 |
+
guidance_scale: float = 7.5,
|
| 112 |
+
steps: int = 30,
|
| 113 |
+
mask_crop_padding: int = 0,
|
| 114 |
+
) -> None:
|
| 115 |
+
result = evaluate.remote(
|
| 116 |
+
candidate_name,
|
| 117 |
+
evaluation_name,
|
| 118 |
+
guidance_scale,
|
| 119 |
+
steps,
|
| 120 |
+
mask_crop_padding,
|
| 121 |
+
)
|
| 122 |
+
print(f"Evaluation is available in Modal Volume {OUTPUT_VOLUME_NAME}: {result}")
|
modal_inpaint_snapshot.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Materialize a resumable training checkpoint as a Diffusers pipeline."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import modal
|
| 11 |
+
|
| 12 |
+
APP_NAME = "clover-image-tiny-inpaint-snapshot"
|
| 13 |
+
OUTPUT_VOLUME_NAME = "clover-image-tiny-inpaint-output"
|
| 14 |
+
CACHE_VOLUME_NAME = "clover-image-tiny-inpaint-cache"
|
| 15 |
+
OUTPUT_ROOT = Path("/outputs")
|
| 16 |
+
CACHE_ROOT = Path("/cache")
|
| 17 |
+
INITIAL_MODEL = "neonforestmist/Clover-Image-Tiny-Inpaint"
|
| 18 |
+
INITIAL_REVISION = "1b6f8ae3db51900520369d5522c7dc7c2a97e21e"
|
| 19 |
+
|
| 20 |
+
image = modal.Image.debian_slim(python_version="3.11").pip_install(
|
| 21 |
+
"accelerate==1.14.0",
|
| 22 |
+
"diffusers==0.39.0",
|
| 23 |
+
"huggingface_hub==0.36.0",
|
| 24 |
+
"safetensors==0.8.0",
|
| 25 |
+
"torch==2.7.0",
|
| 26 |
+
"transformers==4.57.6",
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
output_volume = modal.Volume.from_name(OUTPUT_VOLUME_NAME, create_if_missing=True)
|
| 30 |
+
cache_volume = modal.Volume.from_name(CACHE_VOLUME_NAME, create_if_missing=True)
|
| 31 |
+
app = modal.App(
|
| 32 |
+
APP_NAME,
|
| 33 |
+
image=image,
|
| 34 |
+
volumes={
|
| 35 |
+
str(OUTPUT_ROOT): output_volume,
|
| 36 |
+
str(CACHE_ROOT): cache_volume,
|
| 37 |
+
},
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@app.function(timeout=60 * 60, cpu=4, memory=16384)
|
| 42 |
+
def materialize(source_name: str, checkpoint_step: int, output_name: str) -> str:
|
| 43 |
+
import torch
|
| 44 |
+
from diffusers import StableDiffusionInpaintPipeline
|
| 45 |
+
from safetensors.torch import load_file
|
| 46 |
+
|
| 47 |
+
source = OUTPUT_ROOT / source_name
|
| 48 |
+
checkpoint = source / "checkpoints" / f"checkpoint-{checkpoint_step}"
|
| 49 |
+
weights = checkpoint / "model.safetensors"
|
| 50 |
+
if not weights.is_file():
|
| 51 |
+
raise RuntimeError(f"Missing checkpoint weights: {weights}")
|
| 52 |
+
destination = OUTPUT_ROOT / output_name
|
| 53 |
+
if destination.exists():
|
| 54 |
+
raise RuntimeError(f"Snapshot output already exists: {destination}")
|
| 55 |
+
|
| 56 |
+
os.environ.update(
|
| 57 |
+
{
|
| 58 |
+
"HF_HOME": str(CACHE_ROOT / "huggingface"),
|
| 59 |
+
"HF_HUB_CACHE": str(CACHE_ROOT / "huggingface" / "hub"),
|
| 60 |
+
"TOKENIZERS_PARALLELISM": "false",
|
| 61 |
+
}
|
| 62 |
+
)
|
| 63 |
+
pipeline = StableDiffusionInpaintPipeline.from_pretrained(
|
| 64 |
+
INITIAL_MODEL,
|
| 65 |
+
revision=INITIAL_REVISION,
|
| 66 |
+
torch_dtype=torch.float32,
|
| 67 |
+
)
|
| 68 |
+
state = load_file(str(weights), device="cpu")
|
| 69 |
+
incompatible = pipeline.unet.load_state_dict(state, strict=True)
|
| 70 |
+
if incompatible.missing_keys or incompatible.unexpected_keys:
|
| 71 |
+
raise RuntimeError(f"Checkpoint state mismatch: {incompatible}")
|
| 72 |
+
pipeline.save_pretrained(destination, safe_serialization=True)
|
| 73 |
+
|
| 74 |
+
metadata = {
|
| 75 |
+
"snapshot_type": "training_checkpoint",
|
| 76 |
+
"source_name": source_name,
|
| 77 |
+
"checkpoint_step": checkpoint_step,
|
| 78 |
+
"initial_model": INITIAL_MODEL,
|
| 79 |
+
"initial_revision": INITIAL_REVISION,
|
| 80 |
+
}
|
| 81 |
+
(destination / "snapshot.json").write_text(json.dumps(metadata, indent=2) + "\n")
|
| 82 |
+
(destination / "training-complete.json").write_text(
|
| 83 |
+
json.dumps(metadata, indent=2) + "\n"
|
| 84 |
+
)
|
| 85 |
+
output_volume.commit()
|
| 86 |
+
cache_volume.commit()
|
| 87 |
+
return str(destination)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.local_entrypoint()
|
| 91 |
+
def main(source_name: str, checkpoint_step: int, output_name: str) -> None:
|
| 92 |
+
result = materialize.remote(source_name, checkpoint_step, output_name)
|
| 93 |
+
print(f"Snapshot is available in Modal Volume {OUTPUT_VOLUME_NAME}: {result}")
|
training-complete.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"completed_steps": 500,
|
| 3 |
+
"final_loss": 0.11363611370325089,
|
| 4 |
+
"final_teacher_loss": 0.05577564239501953,
|
| 5 |
+
"final_ground_truth_loss": 0.28721752762794495
|
| 6 |
+
}
|
training-summary.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
| 1 |
{
|
| 2 |
"pretrained_model_name_or_path": "neonforestmist/Clover-Image-Tiny",
|
| 3 |
"revision": "63b0e9f6be9c00888ff464f342a9ef052bf76681",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"dataset_name": "prithivMLmods/Caption3o-Opt",
|
| 5 |
"dataset_config_name": null,
|
| 6 |
"dataset_revision": "17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b",
|
|
@@ -9,19 +13,34 @@
|
|
| 9 |
"caption_column": "caption",
|
| 10 |
"resolution": 512,
|
| 11 |
"train_batch_size": 1,
|
| 12 |
-
"max_train_steps":
|
| 13 |
-
"learning_rate":
|
| 14 |
"lr_scheduler": "cosine",
|
| 15 |
-
"lr_warmup_steps":
|
| 16 |
"gradient_accumulation_steps": 4,
|
| 17 |
"gradient_checkpointing": true,
|
| 18 |
-
"mixed_precision": "
|
| 19 |
-
"seed":
|
| 20 |
-
"mask_min_area": 0.
|
| 21 |
-
"mask_max_area": 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
"max_train_samples": null,
|
| 23 |
-
"
|
| 24 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
"push_to_hub": false,
|
| 26 |
-
"hub_model_id": null
|
|
|
|
|
|
|
| 27 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"pretrained_model_name_or_path": "neonforestmist/Clover-Image-Tiny",
|
| 3 |
"revision": "63b0e9f6be9c00888ff464f342a9ef052bf76681",
|
| 4 |
+
"initial_inpaint_model": "neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 5 |
+
"initial_inpaint_revision": "1b6f8ae3db51900520369d5522c7dc7c2a97e21e",
|
| 6 |
+
"teacher_model_name_or_path": "stable-diffusion-v1-5/stable-diffusion-inpainting",
|
| 7 |
+
"teacher_revision": "8a4288a76071f7280aedbdb3253bdb9e9d5d84bb",
|
| 8 |
"dataset_name": "prithivMLmods/Caption3o-Opt",
|
| 9 |
"dataset_config_name": null,
|
| 10 |
"dataset_revision": "17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b",
|
|
|
|
| 13 |
"caption_column": "caption",
|
| 14 |
"resolution": 512,
|
| 15 |
"train_batch_size": 1,
|
| 16 |
+
"max_train_steps": 500,
|
| 17 |
+
"learning_rate": 5e-06,
|
| 18 |
"lr_scheduler": "cosine",
|
| 19 |
+
"lr_warmup_steps": 25,
|
| 20 |
"gradient_accumulation_steps": 4,
|
| 21 |
"gradient_checkpointing": true,
|
| 22 |
+
"mixed_precision": "bf16",
|
| 23 |
+
"seed": 20260811,
|
| 24 |
+
"mask_min_area": 0.04,
|
| 25 |
+
"mask_max_area": 0.65,
|
| 26 |
+
"caption_dropout_probability": 0.1,
|
| 27 |
+
"teacher_loss_weight": 0.75,
|
| 28 |
+
"ground_truth_loss_weight": 0.25,
|
| 29 |
+
"context_loss_weight": 0.25,
|
| 30 |
+
"masked_loss_weight": 2.5,
|
| 31 |
+
"boundary_loss_weight": 2.0,
|
| 32 |
+
"boundary_radius": 2,
|
| 33 |
+
"snr_gamma": 5.0,
|
| 34 |
+
"random_flip": true,
|
| 35 |
"max_train_samples": null,
|
| 36 |
+
"validation_samples": 128,
|
| 37 |
+
"num_workers": 4,
|
| 38 |
+
"checkpointing_steps": 500,
|
| 39 |
+
"checkpoints_total_limit": 3,
|
| 40 |
+
"resume_from_checkpoint": null,
|
| 41 |
+
"output_dir": "/outputs/clover-image-tiny-inpaint-v2-pilot-500",
|
| 42 |
"push_to_hub": false,
|
| 43 |
+
"hub_model_id": null,
|
| 44 |
+
"dataset_size": 10150,
|
| 45 |
+
"effective_batch_size": 4
|
| 46 |
}
|
training/README-INPAINTING.md
CHANGED
|
@@ -9,10 +9,20 @@ The U-Net input is ordered as:
|
|
| 9 |
[noisy_latent (4), mask (1), masked_image_latent (4)]
|
| 10 |
```
|
| 11 |
|
| 12 |
-
White mask pixels are regenerated; black pixels are preserved.
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
## Modal
|
| 18 |
|
|
@@ -21,26 +31,35 @@ Use that profile before running the commands below:
|
|
| 21 |
|
| 22 |
```bash
|
| 23 |
modal profile activate guccichungus69
|
| 24 |
-
modal run modal_inpaint.py --smoke --steps
|
| 25 |
-
modal run modal_inpaint.py
|
|
|
|
|
|
|
|
|
|
| 26 |
```
|
| 27 |
|
| 28 |
-
The job uses one
|
| 29 |
`clover-image-tiny-inpaint-output` Volume. No Hub token is required for the
|
| 30 |
-
default public dataset.
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
Download a completed output directory with:
|
| 34 |
|
| 35 |
```bash
|
| 36 |
modal volume get clover-image-tiny-inpaint-output \
|
| 37 |
-
clover-image-tiny-inpaint \
|
| 38 |
-
./artifacts/clover-image-tiny-inpaint
|
| 39 |
```
|
| 40 |
|
| 41 |
-
If a job stops
|
| 42 |
-
|
| 43 |
-
|
| 44 |
|
| 45 |
## Local dry run
|
| 46 |
|
|
|
|
| 9 |
[noisy_latent (4), mask (1), masked_image_latent (4)]
|
| 10 |
```
|
| 11 |
|
| 12 |
+
White mask pixels are regenerated; black pixels are preserved. The v2 recipe
|
| 13 |
+
warm-starts from the released nine-channel checkpoint and distills the pinned
|
| 14 |
+
official `stable-diffusion-v1-5/stable-diffusion-inpainting` U-Net. A smaller
|
| 15 |
+
ground-truth diffusion term prevents the student from merely copying teacher
|
| 16 |
+
errors. Min-SNR timestep weighting and a spatial objective emphasize the
|
| 17 |
+
masked region, its boundary, and enough unmasked context to learn coherent
|
| 18 |
+
transitions.
|
| 19 |
+
|
| 20 |
+
Masks are sampled procedurally as free-form strokes, multiple disconnected
|
| 21 |
+
strokes, rounded boxes, ellipses, polygons, multiple object-like regions, and
|
| 22 |
+
outpainting bands. The pinned dataset is `prithivMLmods/Caption3o-Opt` at
|
| 23 |
+
revision `17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b` (Apache-2.0, `image` +
|
| 24 |
+
`caption`). A deterministic 128-image tail is excluded from training and used
|
| 25 |
+
for same-mask, same-seed checkpoint comparison.
|
| 26 |
|
| 27 |
## Modal
|
| 28 |
|
|
|
|
| 31 |
|
| 32 |
```bash
|
| 33 |
modal profile activate guccichungus69
|
| 34 |
+
modal run modal_inpaint.py --smoke --steps 1
|
| 35 |
+
modal run --detach modal_inpaint.py \
|
| 36 |
+
--steps 500 \
|
| 37 |
+
--lr-warmup-steps 25 \
|
| 38 |
+
--output-name clover-image-tiny-inpaint-v2-pilot-500
|
| 39 |
```
|
| 40 |
|
| 41 |
+
The job uses one A10 and writes the finished Diffusers pipeline to the
|
| 42 |
`clover-image-tiny-inpaint-output` Volume. No Hub token is required for the
|
| 43 |
+
default public dataset. Resumable Accelerator checkpoints are committed to the
|
| 44 |
+
persistent Volume. A separate cache avoids downloading the student, teacher,
|
| 45 |
+
and dataset again on every run.
|
| 46 |
+
|
| 47 |
+
The published v2 checkpoint is the 500-step cosine-decay sweep. A longer run
|
| 48 |
+
was rejected after its held-out reconstruction and semantic gates regressed
|
| 49 |
+
into colored high-frequency artifacts. The release pins the best validated
|
| 50 |
+
checkpoint rather than the final checkpoint from the largest job.
|
| 51 |
|
| 52 |
Download a completed output directory with:
|
| 53 |
|
| 54 |
```bash
|
| 55 |
modal volume get clover-image-tiny-inpaint-output \
|
| 56 |
+
clover-image-tiny-inpaint-v2-pilot-500/unet \
|
| 57 |
+
./artifacts/clover-image-tiny-inpaint-v2
|
| 58 |
```
|
| 59 |
|
| 60 |
+
If a job stops after a committed checkpoint, resume the same output with
|
| 61 |
+
`--resume`. Completed output directories are immutable to the launcher; use a
|
| 62 |
+
new `--output-name` for a distinct experiment.
|
| 63 |
|
| 64 |
## Local dry run
|
| 65 |
|
unet/config.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
"_class_name": "UNet2DConditionModel",
|
| 3 |
"_diffusers_version": "0.39.0",
|
| 4 |
-
"_name_or_path": "neonforestmist/Clover-Image-Tiny",
|
| 5 |
"act_fn": "silu",
|
| 6 |
"addition_embed_type": null,
|
| 7 |
"addition_embed_type_num_heads": 64,
|
|
|
|
| 1 |
{
|
| 2 |
"_class_name": "UNet2DConditionModel",
|
| 3 |
"_diffusers_version": "0.39.0",
|
| 4 |
+
"_name_or_path": "neonforestmist/Clover-Image-Tiny-Inpaint",
|
| 5 |
"act_fn": "silu",
|
| 6 |
"addition_embed_type": null,
|
| 7 |
"addition_embed_type_num_heads": 64,
|