File size: 6,548 Bytes
1a25d7e
 
 
 
 
 
 
 
 
 
 
 
 
 
931bf46
1a25d7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5172b8e
1a25d7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5172b8e
 
 
 
1a25d7e
5172b8e
 
 
 
 
 
 
 
 
 
 
1a25d7e
5172b8e
 
 
 
 
 
 
 
 
 
 
 
 
 
1a25d7e
5172b8e
1a25d7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931bf46
 
1a25d7e
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
"""ICTone Hugging Face Spaces demo optimized for ZeroGPU.

Space setup:
1. Select ZeroGPU hardware in the Space settings.
2. Add a Space secret named HF_TOKEN. The token owner must have accepted
   the access conditions for black-forest-labs/FLUX.1-Fill-dev.
3. Keep inference.py in the same directory as this file.
"""

from __future__ import annotations

import os
import random

import gradio as gr
import numpy as np
import spaces
import torch
from diffusers import FluxFillPipeline
from PIL import Image

from inference import (
    DEFAULT_INSTANCE_PROMPT,
    apply_lut,
    estimate_lut,
    run_one,
)


MAX_SEED = np.iinfo(np.int32).max

FLUX_PATH = os.getenv(
    "FLUX_PATH",
    "black-forest-labs/FLUX.1-Fill-dev",
)
LORA_PATH = os.getenv(
    "LORA_PATH",
    "ToneStyle/ICTone-Fill-LoRA",
)
IMAGE_SIZE = int(os.getenv("IMAGE_SIZE", "512"))
LUT_SIZE = 33
HF_TOKEN = os.getenv("HF_TOKEN")


def load_pipeline() -> FluxFillPipeline:
    """Load FluxFill + ICTone LoRA once at Space startup.

    ZeroGPU recommends placing the model on CUDA at module scope. During Space
    startup this uses ZeroGPU's CUDA emulation; a real GPU is attached only
    while a @spaces.GPU function is running.
    """
    print(f"[ICTone] Loading base model: {FLUX_PATH}")
    print(f"[ICTone] Loading LoRA: {LORA_PATH}")

    load_kwargs = {
        "torch_dtype": torch.bfloat16,
    }
    if HF_TOKEN:
        load_kwargs["token"] = HF_TOKEN

    pipe = FluxFillPipeline.from_pretrained(
        FLUX_PATH,
        **load_kwargs,
    )
    pipe.load_lora_weights(LORA_PATH)

    # Required placement pattern for ZeroGPU. Do not lazy-load/move the model
    # inside infer().
    pipe.to("cuda")

    print("[ICTone] Pipeline ready.")
    return pipe


# Load once at module scope for efficient ZeroGPU model placement.
pipe = load_pipeline()


@spaces.GPU(duration=60)
def infer(
    content: Image.Image,
    reference: Image.Image,
    seed: int,
    randomize_seed: bool,
    guidance_scale: float,
    num_inference_steps: int,
    progress=gr.Progress(track_tqdm=True),
):
    """Run ICTone and reconstruct the result at the original content resolution."""
    if content is None or reference is None:
        raise gr.Error("Please upload both a content image and a reference image.")

    if randomize_seed:
        seed = random.randint(0, MAX_SEED)

    seed = int(seed)
    guidance_scale = float(guidance_scale)
    num_inference_steps = int(num_inference_steps)

    content_rgb = content.convert("RGB")
    reference_rgb = reference.convert("RGB")

    with torch.inference_mode():
        pred, panel, _, _ = run_one(
            pipe,
            content_rgb,
            reference_rgb,
            size=IMAGE_SIZE,
            prompt=DEFAULT_INSTANCE_PROMPT,
            guidance_scale=guidance_scale,
            num_inference_steps=num_inference_steps,
            seed=seed,
            generator_device="cuda",
        )

        # Lift the low-resolution Flux prediction back to the original content
        # resolution using ICTone's fitted 3D LUT.
        before = np.asarray(content_rgb)
        after = np.asarray(
            pred.resize(content_rgb.size, Image.Resampling.BILINEAR)
        )

        before_flat = before.reshape(-1, 3)
        after_flat = after.reshape(-1, 3)

        # Bound LUT fitting cost for very large uploaded images.
        max_samples = 500_000
        if len(before_flat) > max_samples:
            rng = np.random.default_rng(0)
            selected = rng.choice(
                len(before_flat),
                max_samples,
                replace=False,
            )
            before_flat = before_flat[selected]
            after_flat = after_flat[selected]

        lut = estimate_lut(
            before_flat,
            after_flat,
            size=LUT_SIZE,
            device="cuda",
        )
        output = Image.fromarray(
            apply_lut(
                before,
                lut,
                device="cuda",
            )
        )

    return output, panel, seed


with gr.Blocks(title="ICTone · In-Context Tone Style Transfer") as demo:
    gr.Markdown(
        """
# ICTone

**In-Context Tone Style Transfer**

Upload a **content image** and a **reference image**. ICTone transfers the
reference color, contrast, and photographic tone while preserving the content
of the source image.

The demo uses **FLUX.1-Fill-dev** with the **ICTone LoRA** and runs on
Hugging Face **ZeroGPU**. A short queue may appear when shared GPUs are busy.
"""
    )

    with gr.Row():
        content = gr.Image(
            label="Content image",
            type="pil",
        )
        reference = gr.Image(
            label="Reference image",
            type="pil",
        )

    with gr.Accordion("Generation settings", open=False):
        with gr.Row():
            seed = gr.Number(
                label="Seed",
                value=666,
                precision=0,
            )
            randomize_seed = gr.Checkbox(
                label="Randomize seed",
                value=False,
            )
        with gr.Row():
            guidance = gr.Slider(
                label="Guidance scale",
                minimum=1,
                maximum=100,
                value=50,
                step=1,
            )
            steps = gr.Slider(
                label="Inference steps",
                minimum=1,
                maximum=28,
                value=4,
                step=1,
            )

    run = gr.Button(
        "Transfer tone",
        variant="primary",
    )

    with gr.Row():
        output = gr.Image(
            label="Result",
            type="pil",
        )
        preview = gr.Image(
            label="Content | Reference | Result",
            type="pil",
        )

    used_seed = gr.Number(
        label="Used seed",
        precision=0,
    )

    run.click(
        fn=infer,
        inputs=[
            content,
            reference,
            seed,
            randomize_seed,
            guidance,
            steps,
        ],
        outputs=[
            output,
            preview,
            used_seed,
        ],
        show_progress="full",
    )

    gr.Markdown(
        """
**Models:** `black-forest-labs/FLUX.1-Fill-dev` +
`ToneStyle/ICTone-Fill-LoRA`

FLUX.1-Fill-dev is subject to the FLUX.1 [dev] license and access conditions.
"""
    )


if __name__ == "__main__":
    demo.queue().launch(
        server_name="0.0.0.0",
        server_port=int(os.getenv("PORT", "7860")),
    )