neonforestmist commited on
Commit
95bf78b
·
verified ·
1 Parent(s): 61f8bcb

Document and package context-aware inpainting v2

Browse files

Adds selected training provenance, release metrics, real inpainting examples, runtime recommendations, and current training/evaluation sources.

.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
- 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
 
22
  ```text
23
  [noisy latent (4), mask (1), masked-image latent (4)]
24
  ```
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
- padding_mask_crop=64,
 
47
  ).images[0]
48
- image.save("clover-inpaint.png")
49
  ```
50
 
51
- The mask is a grayscale image: white means “regenerate” and black means
52
- “preserve. A few useful inpainting prompts are:
 
 
 
 
53
 
54
- - `replace the masked area with a tiny glass greenhouse glowing at night`
55
- - `remove the person from the masked area and continue the background naturally`
56
- - `add a red enamel kettle on the masked countertop`
57
 
58
- ## Inpainting example
59
 
60
- The example below uses DPM-Solver++, 20 steps, CFG 7.5, and seed 1337. The
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
- | ![Greenhouse source](examples/source-greenhouse.png) | ![White doorway mask](examples/mask-doorway.png) | ![Cat inpainted into the doorway](examples/result-cat.png) |
67
 
68
- For small masks, `padding_mask_crop=64` gives the edit more useful latent
69
- resolution before Diffusers maps it back into the source image. DPM-Solver++
70
- is the recommended scheduler for this release; PNDM can become unstable at
71
- higher step counts. Start at 20 steps and keep interactive runs at or below 50.
72
 
73
- For repeatable experiments, keep the input image, mask, seed, scheduler, and
74
- step count together. Inpainting is local editing: the unmasked region is
75
- provided as the masked-image conditioning and is also preserved by the native
76
- runtime compositor.
77
 
78
- ## Core ML and SD 1.4-class deployment
79
 
80
- The companion Core ML resource bundle is converted for iOS 18 with a
81
- batch-one U-Net and chunked U-Net resources for the SD 1.4-class 512×512
82
- architecture. The Swift runtime performs classifier-free guidance as two
83
- serial passes to reduce peak memory. The
84
- bundled `VAEEncoder.mlmodelc` creates the masked-image latent locally, so the
85
- input image and mask do not leave the device.
86
 
87
- Conversion and the native iOS integration live in the source Clover repo:
 
 
88
 
89
- - [`coreml-tools/convert_inpaint.sh`](https://huggingface.co/neonforestmist/Clover-Image-Tiny/blob/main/coreml-tools/convert_inpaint.sh)
90
- - [`Clover-iOS`](https://huggingface.co/neonforestmist/Clover-Image-Tiny/tree/main/Clover-iOS)
91
- - [`training/README-INPAINTING.md`](https://huggingface.co/neonforestmist/Clover-Image-Tiny/blob/main/training/README-INPAINTING.md)
92
 
93
- ## LoRA compatibility
 
 
94
 
95
- Diffusers exposes LoRA loading for inpainting pipelines, but an adapter must be
96
- trained against this model's 9-channel U-Net. Existing Clover Image Tiny style
97
- LoRAs target the Regular 4-channel U-Net and are not interchangeable. The
98
- current Core ML export does not expose dynamic LoRA state; an inpainting-specific
99
- adapter can instead be fused and converted as a separate bundle.
 
 
 
 
 
 
100
 
101
  ## Training provenance
102
 
103
- Training uses synthetic rectangle, ellipse, and brush masks over the pinned
104
- Apache-2.0 `prithivMLmods/Caption3o-Opt` image-caption dataset. The full job is
105
- launched by Modal under the `guccichungus69` workspace and stores its output in
106
- the `clover-image-tiny-inpaint-output` Volume before Core ML conversion.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
- ## Citation
 
 
 
 
 
109
 
110
- If Clover Image Tiny Inpaint is useful in your work, please cite the release:
 
 
 
 
 
 
 
 
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
+ | ![Park bench source](examples/cat-source.png) | ![Cat edit mask](examples/cat-mask.png) | ![Cat inpaint result](examples/cat-result.png) |
73
 
74
+ Prompt: `a glossy red enamel kettle resting naturally on the countertop`
 
 
 
75
 
76
+ | Source | White mask | Result |
77
+ |:---:|:---:|:---:|
78
+ | ![Kitchen source](examples/kettle-source.png) | ![Kettle edit mask](examples/kettle-mask.png) | ![Kettle inpaint result](examples/kettle-result.png) |
 
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
+ | ![Street source](examples/bicycle-source.png) | ![Bicycle edit mask](examples/bicycle-mask.png) | ![Bicycle inpaint result](examples/bicycle-result.png) |
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

  • SHA256: f371aa3da3cdaed7d96ee2e31cd3777dc088b9ad6550c1372c7f172af27ba75a
  • Pointer size: 131 Bytes
  • Size of remote file: 619 kB
examples/cat-source.png ADDED

Git LFS Details

  • SHA256: 599d9d515915a96dd81fe9a64eac90a99a114009a35fffb4252e0a9a10057d98
  • Pointer size: 131 Bytes
  • Size of remote file: 630 kB
examples/kettle-mask.png ADDED
examples/kettle-result.png ADDED

Git LFS Details

  • SHA256: dd30d29a82bd1f33d80cb11ded959d882ae06af68aa2a1deeeb8cdd54e470c48
  • Pointer size: 131 Bytes
  • Size of remote file: 349 kB
examples/kettle-source.png ADDED

Git LFS Details

  • SHA256: a70272a14b0fce4f36241ce8426cffaf46f1cde97d24aa2f887b4bb6deced369
  • Pointer size: 131 Bytes
  • Size of remote file: 342 kB
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
- "base_model": "neonforestmist/Clover-Image-Tiny",
5
- "base_revision": "63b0e9f6be9c00888ff464f342a9ef052bf76681"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-inpainting
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 iPhone.
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=30,
 
 
45
  ).images[0]
46
  image.save("clover-inpaint.png")
47
  ```
48
 
49
- ## Core ML and iPhone 15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. The Swift runtime performs
53
- classifier-free guidance as two serial passes to reduce peak memory. The
 
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
- Training uses synthetic rectangle, ellipse, and brush masks over the pinned
66
- Apache-2.0 `prithivMLmods/Caption3o-Opt` image-caption dataset. The full job is
67
- launched by Modal under the `guccichungus69` workspace and stores its output in
68
- the `clover-image-tiny-inpaint-output` Volume before Core ML conversion.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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__ = ["model", "masks"]
 
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": 4000,
14
- "learning_rate": 0.00001,
15
- "warmup_steps": 200,
16
  "train_batch_size": 1,
17
  "gradient_accumulation_steps": 4,
18
- "mixed_precision": "fp16",
 
19
  "gradient_checkpointing": true,
20
- "mask_min_area": 0.12,
21
- "mask_max_area": 0.55
 
 
 
 
 
 
 
 
 
 
22
  },
23
  "modal": {
24
  "profile": "guccichungus69",
25
- "gpu": "A10G",
26
- "timeout_hours": 4,
27
- "estimated_hourly_usd": 1.0
 
 
 
 
 
 
 
 
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
- """Synthetic mask generation used for inpainting fine-tuning."""
2
 
3
  from __future__ import annotations
4
 
 
5
  import random
6
 
7
- from PIL import Image, ImageChops, ImageDraw
8
 
 
 
 
 
 
 
 
 
 
9
 
10
- def random_mask(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  size: tuple[int, int],
12
  rng: random.Random,
13
  *,
14
- min_area: float = 0.12,
15
- max_area: float = 0.55,
16
- ) -> Image.Image:
17
- """Return a black/white L mask where white means "paint this area"."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
 
 
 
 
 
 
 
19
  width, height = size
20
  mask = Image.new("L", size, 0)
21
  draw = ImageDraw.Draw(mask)
22
- shape = rng.choice(("rectangle", "ellipse", "brush"))
23
-
24
- target_area = rng.uniform(min_area, max_area) * width * height
25
- if shape in {"rectangle", "ellipse"}:
26
- aspect = rng.uniform(0.45, 2.2)
27
- box_width = max(8, int((target_area * aspect) ** 0.5))
28
- box_height = max(8, int((target_area / aspect) ** 0.5))
29
- box_width = min(box_width, width)
30
- box_height = min(box_height, height)
31
- left = rng.randint(0, max(0, width - box_width))
32
- top = rng.randint(0, max(0, height - box_height))
33
- box = (left, top, left + box_width, top + box_height)
34
- if shape == "rectangle":
35
- draw.rectangle(box, fill=255)
36
- else:
37
- draw.ellipse(box, fill=255)
38
- else:
39
- # A few overlapping strokes cover irregular object-shaped regions while
40
- # staying cheap enough to generate inside a DataLoader worker.
41
- stroke_width = max(8, int(min(width, height) * rng.uniform(0.06, 0.18)))
42
- points = [
43
- (
44
- rng.randint(0, width - 1),
45
- rng.randint(0, height - 1),
46
  )
47
- for _ in range(rng.randint(2, 5))
48
- ]
49
- draw.line(points, fill=255, width=stroke_width, joint="curve")
50
- radius = stroke_width // 2
51
- for x, y in points:
52
- draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=255)
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- return mask
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- """Fine-tune Clover Image Tiny into a 9-channel inpainting pipeline.
3
 
4
- The dataset only needs an image column and a text-caption column. Masks are
5
- generated on the fly, so one image produces many distinct training examples.
 
 
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=4000)
48
- parser.add_argument("--learning_rate", type=float, default=1e-5)
49
  parser.add_argument("--lr_scheduler", default="cosine")
50
- parser.add_argument("--lr_warmup_steps", type=int, default=200)
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="fp16")
54
- parser.add_argument("--seed", type=int, default=20260810)
55
- parser.add_argument("--mask_min_area", type=float, default=0.12)
56
- parser.add_argument("--mask_max_area", type=float, default=0.55)
 
 
 
 
 
 
 
 
 
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 image_to_tensor(image: Image.Image, resolution: int) -> torch.Tensor:
70
- transform = transforms.Compose(
71
- [
72
- transforms.Resize(resolution, interpolation=transforms.InterpolationMode.BILINEAR),
73
- transforms.CenterCrop(resolution),
74
- transforms.ToTensor(),
75
- transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]),
76
- ]
77
- )
78
- return transform(image.convert("RGB"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = random.Random(seed)
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 = image.convert("RGB")
102
- image = transforms.Resize(
103
- resolution,
104
- interpolation=transforms.InterpolationMode.BILINEAR,
105
- )(image)
106
- image = transforms.CenterCrop(resolution)(image)
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(image_to_tensor(image, resolution))
116
- masked_images.append(image_to_tensor(masked, resolution))
117
- mask_tensor = torch.from_numpy(
118
- __import__("numpy").array(mask, dtype="float32") / 255.0
119
- ).unsqueeze(0)
120
- masks.append(mask_tensor)
121
- caption = example["caption"]
122
- if isinstance(caption, list):
123
- caption = caption[0] if caption else ""
124
- captions.append(str(caption or ""))
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 save_pipeline(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  *,
145
- base_model: str,
146
- revision: str | None,
147
- unet,
148
  output_dir: Path,
149
  ) -> None:
150
  output_dir.mkdir(parents=True, exist_ok=True)
151
  pipeline = StableDiffusionInpaintPipeline.from_pretrained(
152
- base_model,
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": base_model,
163
- "base_revision": revision,
 
 
 
 
 
 
164
  },
165
  indent=2,
166
  )
@@ -168,28 +249,60 @@ def save_pipeline(
168
  )
169
 
170
 
171
- def main() -> None:
172
- args = parse_args()
 
 
 
 
 
 
 
 
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
- unet = make_inpainting_unet(
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
- unet.enable_gradient_checkpointing()
230
- text_encoder.requires_grad_(False)
231
- vae.requires_grad_(False)
232
- text_encoder.eval()
233
- vae.eval()
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=2,
248
  pin_memory=True,
 
249
  )
250
  optimizer = torch.optim.AdamW(
251
- unet.parameters(),
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
- unet, optimizer, dataloader, lr_scheduler = accelerator.prepare(
265
- unet, optimizer, dataloader, lr_scheduler
266
  )
267
- weight_dtype = torch.float32
268
- if accelerator.mixed_precision == "fp16":
269
- weight_dtype = torch.float16
270
- elif accelerator.mixed_precision == "bf16":
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(unet):
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(batch["input_ids"].to(accelerator.device))[0]
 
 
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
- mask = F.interpolate(mask, size=latents.shape[-2:], mode="nearest")
310
- model_input = torch.cat([noisy_latents, mask, masked_latents], dim=1)
311
- model_pred = unet(
312
  model_input,
313
  timesteps,
314
  encoder_hidden_states=encoder_hidden_states,
315
  ).sample
316
 
317
- if noise_scheduler.config.prediction_type == "epsilon":
318
- target = noise
319
- elif noise_scheduler.config.prediction_type == "v_prediction":
320
- target = noise_scheduler.get_velocity(latents, noise, timesteps)
 
321
  else:
322
- raise ValueError(
323
- f"Unsupported prediction type: {noise_scheduler.config.prediction_type}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  )
325
- loss = F.mse_loss(model_pred.float(), target.float(), reduction="mean")
 
 
 
 
326
  accelerator.backward(loss)
327
  if accelerator.sync_gradients:
328
- accelerator.clip_grad_norm_(unet.parameters(), 1.0)
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
- if accelerator.is_main_process and global_step % 50 == 0:
 
 
 
 
 
 
 
 
336
  accelerator.print(
337
  f"step={global_step}/{args.max_train_steps} "
338
- f"loss={loss.detach().item():.4f} "
339
- f"lr={lr_scheduler.get_last_lr()[0]:.3e}"
 
 
 
340
  )
341
- accelerator.log(
342
- {"train_loss": loss.detach().item(), "lr": lr_scheduler.get_last_lr()[0]},
343
- step=global_step,
344
- )
 
 
 
 
 
 
 
 
 
345
 
346
  accelerator.wait_for_everyone()
347
  if accelerator.is_main_process:
348
- unwrapped = accelerator.unwrap_model(unet).cpu()
349
- save_pipeline(
350
- base_model=args.pretrained_model_name_or_path,
351
- revision=args.revision,
352
- unet=unwrapped,
353
- output_dir=args.output_dir,
354
- )
355
- summary = vars(args).copy()
356
- summary["output_dir"] = str(args.output_dir)
357
- (args.output_dir / "training-summary.json").write_text(
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 training on Modal.
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={str(OUTPUT_ROOT): output_volume},
 
 
 
46
  )
47
 
48
 
49
- @app.function(gpu="A10G", timeout=4 * 60 * 60)
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 = 4000,
60
- output_name: str = "clover-image-tiny-inpaint",
61
  max_train_samples: int | None = None,
62
- learning_rate: float = 1e-5,
63
- seed: int = 20260810,
 
 
 
 
 
 
 
 
64
  ) -> str:
65
- """Train once and return the Modal Volume path containing the pipeline."""
66
 
67
  output_dir = OUTPUT_ROOT / output_name
68
- if output_dir.exists():
69
- raise RuntimeError(f"Output already exists; choose another --output-name: {output_dir}")
 
 
 
 
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
- "fp16",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.setdefault("HF_HOME", "/root/.cache/huggingface")
107
- subprocess.run(command, check=True, env=env)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 4000,
116
- output_name: str = "clover-image-tiny-inpaint",
 
 
 
 
 
 
 
 
 
 
117
  ) -> None:
118
- """Launch a bounded smoke job or the full A10G run."""
119
 
120
  if smoke:
121
- steps = min(steps, 2)
122
- samples = 4
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": 4000,
13
- "learning_rate": 1e-05,
14
  "lr_scheduler": "cosine",
15
- "lr_warmup_steps": 200,
16
  "gradient_accumulation_steps": 4,
17
  "gradient_checkpointing": true,
18
- "mixed_precision": "fp16",
19
- "seed": 20260810,
20
- "mask_min_area": 0.12,
21
- "mask_max_area": 0.55,
 
 
 
 
 
 
 
 
 
22
  "max_train_samples": null,
23
- "output_dir": "/outputs/clover-image-tiny-inpaint",
24
- "validation_prompt": null,
 
 
 
 
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. Masks are
13
- sampled procedurally during training, so the dataset only needs image/caption
14
- pairs. The pinned default dataset is `prithivMLmods/Caption3o-Opt` at revision
15
- `17e893f785fcd3f5d6fc4a5d65a914b9f7b1ff5b` (Apache-2.0, `image` + `caption`).
 
 
 
 
 
 
 
 
 
 
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 2
25
- modal run modal_inpaint.py --steps 4000
 
 
 
26
  ```
27
 
28
- The job uses one A10G and writes the finished Diffusers pipeline to the
29
  `clover-image-tiny-inpaint-output` Volume. No Hub token is required for the
30
- default public dataset. The estimated compute ceiling for the default four-hour
31
- job is about $4 at $1/hour; the actual charge depends on runtime.
 
 
 
 
 
 
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 before writing its final pipeline, the Volume retains any files
42
- that were committed. The current trainer saves only at the end, so use a
43
- separate `--output-name` for retries.
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,