Spaces:
Running on Zero
Running on Zero
update app.pyp
Browse files- app.py +269 -4
- inference.py +753 -0
- requirements.txt +18 -0
app.py
CHANGED
|
@@ -1,7 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
def greet(name):
|
| 4 |
-
return "Hello " + name + "!!"
|
| 5 |
|
| 6 |
-
|
| 7 |
-
demo.
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ICTone Hugging Face Spaces demo optimized for ZeroGPU.
|
| 2 |
+
|
| 3 |
+
Space setup:
|
| 4 |
+
1. Select ZeroGPU hardware in the Space settings.
|
| 5 |
+
2. Add a Space secret named HF_TOKEN. The token owner must have accepted
|
| 6 |
+
the access conditions for black-forest-labs/FLUX.1-Fill-dev.
|
| 7 |
+
3. Keep inference.py in the same directory as this file.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import random
|
| 14 |
+
|
| 15 |
import gradio as gr
|
| 16 |
+
import numpy as np
|
| 17 |
+
import spaces
|
| 18 |
+
import torch
|
| 19 |
+
from diffusers import FluxFillPipeline
|
| 20 |
+
from PIL import Image
|
| 21 |
+
|
| 22 |
+
from inference import (
|
| 23 |
+
DEFAULT_INSTANCE_PROMPT,
|
| 24 |
+
apply_lut,
|
| 25 |
+
estimate_lut,
|
| 26 |
+
run_one,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
MAX_SEED = np.iinfo(np.int32).max
|
| 31 |
+
|
| 32 |
+
FLUX_PATH = os.getenv(
|
| 33 |
+
"FLUX_PATH",
|
| 34 |
+
"black-forest-labs/FLUX.1-Fill-dev",
|
| 35 |
+
)
|
| 36 |
+
LORA_PATH = os.getenv(
|
| 37 |
+
"LORA_PATH",
|
| 38 |
+
"ToneStyle/ICTone-Fill-LoRA",
|
| 39 |
+
)
|
| 40 |
+
IMAGE_SIZE = int(os.getenv("IMAGE_SIZE", "512"))
|
| 41 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def load_pipeline() -> FluxFillPipeline:
|
| 45 |
+
"""Load FluxFill + ICTone LoRA once at Space startup.
|
| 46 |
+
|
| 47 |
+
ZeroGPU recommends placing the model on CUDA at module scope. During Space
|
| 48 |
+
startup this uses ZeroGPU's CUDA emulation; a real GPU is attached only
|
| 49 |
+
while a @spaces.GPU function is running.
|
| 50 |
+
"""
|
| 51 |
+
print(f"[ICTone] Loading base model: {FLUX_PATH}")
|
| 52 |
+
print(f"[ICTone] Loading LoRA: {LORA_PATH}")
|
| 53 |
+
|
| 54 |
+
load_kwargs = {
|
| 55 |
+
"torch_dtype": torch.bfloat16,
|
| 56 |
+
}
|
| 57 |
+
if HF_TOKEN:
|
| 58 |
+
load_kwargs["token"] = HF_TOKEN
|
| 59 |
+
|
| 60 |
+
pipe = FluxFillPipeline.from_pretrained(
|
| 61 |
+
FLUX_PATH,
|
| 62 |
+
**load_kwargs,
|
| 63 |
+
)
|
| 64 |
+
pipe.load_lora_weights(LORA_PATH)
|
| 65 |
+
|
| 66 |
+
# Required placement pattern for ZeroGPU. Do not lazy-load/move the model
|
| 67 |
+
# inside infer().
|
| 68 |
+
pipe.to("cuda")
|
| 69 |
+
|
| 70 |
+
print("[ICTone] Pipeline ready.")
|
| 71 |
+
return pipe
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# Load once at module scope for efficient ZeroGPU model placement.
|
| 75 |
+
pipe = load_pipeline()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@spaces.GPU(duration=60)
|
| 79 |
+
def infer(
|
| 80 |
+
content: Image.Image,
|
| 81 |
+
reference: Image.Image,
|
| 82 |
+
seed: int,
|
| 83 |
+
randomize_seed: bool,
|
| 84 |
+
guidance_scale: float,
|
| 85 |
+
num_inference_steps: int,
|
| 86 |
+
lut_size: int,
|
| 87 |
+
progress=gr.Progress(track_tqdm=True),
|
| 88 |
+
):
|
| 89 |
+
"""Run ICTone and reconstruct the result at the original content resolution."""
|
| 90 |
+
if content is None or reference is None:
|
| 91 |
+
raise gr.Error("Please upload both a content image and a reference image.")
|
| 92 |
+
|
| 93 |
+
if randomize_seed:
|
| 94 |
+
seed = random.randint(0, MAX_SEED)
|
| 95 |
+
|
| 96 |
+
seed = int(seed)
|
| 97 |
+
guidance_scale = float(guidance_scale)
|
| 98 |
+
num_inference_steps = int(num_inference_steps)
|
| 99 |
+
lut_size = int(lut_size)
|
| 100 |
+
|
| 101 |
+
content_rgb = content.convert("RGB")
|
| 102 |
+
reference_rgb = reference.convert("RGB")
|
| 103 |
+
|
| 104 |
+
with torch.inference_mode():
|
| 105 |
+
pred, panel, _, _ = run_one(
|
| 106 |
+
pipe,
|
| 107 |
+
content_rgb,
|
| 108 |
+
reference_rgb,
|
| 109 |
+
size=IMAGE_SIZE,
|
| 110 |
+
prompt=DEFAULT_INSTANCE_PROMPT,
|
| 111 |
+
guidance_scale=guidance_scale,
|
| 112 |
+
num_inference_steps=num_inference_steps,
|
| 113 |
+
seed=seed,
|
| 114 |
+
generator_device="cuda",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# Lift the low-resolution Flux prediction back to the original content
|
| 118 |
+
# resolution using ICTone's fitted 3D LUT.
|
| 119 |
+
if lut_size > 1:
|
| 120 |
+
before = np.asarray(content_rgb)
|
| 121 |
+
after = np.asarray(
|
| 122 |
+
pred.resize(content_rgb.size, Image.Resampling.BILINEAR)
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
before_flat = before.reshape(-1, 3)
|
| 126 |
+
after_flat = after.reshape(-1, 3)
|
| 127 |
+
|
| 128 |
+
# Bound LUT fitting cost for very large uploaded images.
|
| 129 |
+
max_samples = 500_000
|
| 130 |
+
if len(before_flat) > max_samples:
|
| 131 |
+
rng = np.random.default_rng(0)
|
| 132 |
+
selected = rng.choice(
|
| 133 |
+
len(before_flat),
|
| 134 |
+
max_samples,
|
| 135 |
+
replace=False,
|
| 136 |
+
)
|
| 137 |
+
before_flat = before_flat[selected]
|
| 138 |
+
after_flat = after_flat[selected]
|
| 139 |
+
|
| 140 |
+
lut = estimate_lut(
|
| 141 |
+
before_flat,
|
| 142 |
+
after_flat,
|
| 143 |
+
size=lut_size,
|
| 144 |
+
device="cuda",
|
| 145 |
+
)
|
| 146 |
+
output = Image.fromarray(
|
| 147 |
+
apply_lut(
|
| 148 |
+
before,
|
| 149 |
+
lut,
|
| 150 |
+
device="cuda",
|
| 151 |
+
)
|
| 152 |
+
)
|
| 153 |
+
else:
|
| 154 |
+
output = pred
|
| 155 |
+
|
| 156 |
+
return output, panel, seed
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
with gr.Blocks(title="ICTone · In-Context Tone Style Transfer") as demo:
|
| 160 |
+
gr.Markdown(
|
| 161 |
+
"""
|
| 162 |
+
# ICTone
|
| 163 |
+
|
| 164 |
+
**In-Context Tone Style Transfer**
|
| 165 |
+
|
| 166 |
+
Upload a **content image** and a **reference image**. ICTone transfers the
|
| 167 |
+
reference color, contrast, and photographic tone while preserving the content
|
| 168 |
+
of the source image.
|
| 169 |
+
|
| 170 |
+
The demo uses **FLUX.1-Fill-dev** with the **ICTone LoRA** and runs on
|
| 171 |
+
Hugging Face **ZeroGPU**. A short queue may appear when shared GPUs are busy.
|
| 172 |
+
"""
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
with gr.Row():
|
| 176 |
+
content = gr.Image(
|
| 177 |
+
label="Content image",
|
| 178 |
+
type="pil",
|
| 179 |
+
)
|
| 180 |
+
reference = gr.Image(
|
| 181 |
+
label="Reference image",
|
| 182 |
+
type="pil",
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
with gr.Accordion("Generation settings", open=False):
|
| 186 |
+
with gr.Row():
|
| 187 |
+
seed = gr.Number(
|
| 188 |
+
label="Seed",
|
| 189 |
+
value=666,
|
| 190 |
+
precision=0,
|
| 191 |
+
)
|
| 192 |
+
randomize_seed = gr.Checkbox(
|
| 193 |
+
label="Randomize seed",
|
| 194 |
+
value=False,
|
| 195 |
+
)
|
| 196 |
+
with gr.Row():
|
| 197 |
+
guidance = gr.Slider(
|
| 198 |
+
label="Guidance scale",
|
| 199 |
+
minimum=1,
|
| 200 |
+
maximum=100,
|
| 201 |
+
value=50,
|
| 202 |
+
step=1,
|
| 203 |
+
)
|
| 204 |
+
steps = gr.Slider(
|
| 205 |
+
label="Inference steps",
|
| 206 |
+
minimum=1,
|
| 207 |
+
maximum=28,
|
| 208 |
+
value=4,
|
| 209 |
+
step=1,
|
| 210 |
+
)
|
| 211 |
+
lut = gr.Slider(
|
| 212 |
+
label="LUT size",
|
| 213 |
+
minimum=0,
|
| 214 |
+
maximum=33,
|
| 215 |
+
value=33,
|
| 216 |
+
step=1,
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
run = gr.Button(
|
| 220 |
+
"Transfer tone",
|
| 221 |
+
variant="primary",
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
with gr.Row():
|
| 225 |
+
output = gr.Image(
|
| 226 |
+
label="Result",
|
| 227 |
+
type="pil",
|
| 228 |
+
)
|
| 229 |
+
preview = gr.Image(
|
| 230 |
+
label="Content | Reference | Result",
|
| 231 |
+
type="pil",
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
used_seed = gr.Number(
|
| 235 |
+
label="Used seed",
|
| 236 |
+
precision=0,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
run.click(
|
| 240 |
+
fn=infer,
|
| 241 |
+
inputs=[
|
| 242 |
+
content,
|
| 243 |
+
reference,
|
| 244 |
+
seed,
|
| 245 |
+
randomize_seed,
|
| 246 |
+
guidance,
|
| 247 |
+
steps,
|
| 248 |
+
lut,
|
| 249 |
+
],
|
| 250 |
+
outputs=[
|
| 251 |
+
output,
|
| 252 |
+
preview,
|
| 253 |
+
used_seed,
|
| 254 |
+
],
|
| 255 |
+
show_progress="full",
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
gr.Markdown(
|
| 259 |
+
"""
|
| 260 |
+
**Models:** `black-forest-labs/FLUX.1-Fill-dev` +
|
| 261 |
+
`ToneStyle/ICTone-Fill-LoRA`
|
| 262 |
+
|
| 263 |
+
FLUX.1-Fill-dev is subject to the FLUX.1 [dev] license and access conditions.
|
| 264 |
+
"""
|
| 265 |
+
)
|
| 266 |
|
|
|
|
|
|
|
| 267 |
|
| 268 |
+
if __name__ == "__main__":
|
| 269 |
+
demo.queue().launch(
|
| 270 |
+
server_name="0.0.0.0",
|
| 271 |
+
server_port=int(os.getenv("PORT", "7860")),
|
| 272 |
+
)
|
inference.py
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from diffusers import FluxFillPipeline
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
DEFAULT_INSTANCE_PROMPT = (
|
| 14 |
+
"A side-by-side triptych. Left: source photo. "
|
| 15 |
+
"Middle: a color and tone reference photo. "
|
| 16 |
+
"Right: the same scene as the left, re-graded so its colors, "
|
| 17 |
+
"contrast, and film look match the middle reference, while "
|
| 18 |
+
"preserving the left's content and details."
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _resize_to_wh(pil: Image.Image, width: int, height: int) -> Image.Image:
|
| 23 |
+
"""Aspect-preserving resize-cover then center-crop to (width, height)."""
|
| 24 |
+
if pil.mode != "RGB":
|
| 25 |
+
pil = pil.convert("RGB")
|
| 26 |
+
w, h = pil.size
|
| 27 |
+
s = max(width / w, height / h)
|
| 28 |
+
nw = max(int(round(w * s)), width)
|
| 29 |
+
nh = max(int(round(h * s)), height)
|
| 30 |
+
pil = pil.resize((nw, nh), Image.LANCZOS)
|
| 31 |
+
left = (nw - width) // 2
|
| 32 |
+
top = (nh - height) // 2
|
| 33 |
+
return pil.crop((left, top, left + width, top + height))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _resize_width_keep_aspect(
|
| 37 |
+
pil: Image.Image, width: int, height_multiple: int = 16
|
| 38 |
+
) -> Image.Image:
|
| 39 |
+
"""Resize so output width == ``width``, height scales by original aspect.
|
| 40 |
+
|
| 41 |
+
Height is rounded to the nearest positive multiple of ``height_multiple``
|
| 42 |
+
(FluxFill's VAE + patch stride, so the pipeline accepts it without extra
|
| 43 |
+
padding). No cropping is performed.
|
| 44 |
+
"""
|
| 45 |
+
if pil.mode != "RGB":
|
| 46 |
+
pil = pil.convert("RGB")
|
| 47 |
+
w, h = pil.size
|
| 48 |
+
new_h = int(round(h * width / w))
|
| 49 |
+
if height_multiple > 1:
|
| 50 |
+
new_h = int(round(new_h / height_multiple)) * height_multiple
|
| 51 |
+
new_h = max(height_multiple, new_h)
|
| 52 |
+
else:
|
| 53 |
+
new_h = max(1, new_h)
|
| 54 |
+
return pil.resize((width, new_h), Image.LANCZOS)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _build_lut_laplacian(size: int):
|
| 58 |
+
"""Sparse 3D graph-Laplacian on the (size, size, size) LUT grid.
|
| 59 |
+
|
| 60 |
+
Each vertex has up to 6 axis-aligned neighbors. ``L L^T`` acts as a
|
| 61 |
+
curvature penalty so uncovered / sparsely-covered LUT cells extrapolate
|
| 62 |
+
smoothly from their neighbors instead of collapsing to a fixed anchor.
|
| 63 |
+
"""
|
| 64 |
+
from scipy.sparse import coo_matrix
|
| 65 |
+
V = size ** 3
|
| 66 |
+
idx = np.arange(V, dtype=np.int64).reshape(size, size, size)
|
| 67 |
+
rows: list[np.ndarray] = []
|
| 68 |
+
cols: list[np.ndarray] = []
|
| 69 |
+
data: list[np.ndarray] = []
|
| 70 |
+
for axis in range(3):
|
| 71 |
+
# Pair each vertex with its +1-neighbor along ``axis`` (no wrap-around).
|
| 72 |
+
take = [slice(None)] * 3
|
| 73 |
+
take[axis] = slice(None, -1)
|
| 74 |
+
src = idx[tuple(take)].ravel()
|
| 75 |
+
take[axis] = slice(1, None)
|
| 76 |
+
dst = idx[tuple(take)].ravel()
|
| 77 |
+
# Edge (src -> dst): L[src] - L[dst] = 0 (finite difference row).
|
| 78 |
+
n = src.shape[0]
|
| 79 |
+
edge_rows = np.arange(n, dtype=np.int64) + sum(r.shape[0] for r in rows) // 2 * 0 # local re-baseline below
|
| 80 |
+
# We'll assemble a single edge-per-row Laplacian by concatenating below.
|
| 81 |
+
rows.append(src)
|
| 82 |
+
cols.append(src)
|
| 83 |
+
data.append(np.ones(n, dtype=np.float32))
|
| 84 |
+
rows.append(src)
|
| 85 |
+
cols.append(dst)
|
| 86 |
+
data.append(-np.ones(n, dtype=np.float32))
|
| 87 |
+
rows.append(dst)
|
| 88 |
+
cols.append(dst)
|
| 89 |
+
data.append(np.ones(n, dtype=np.float32))
|
| 90 |
+
rows.append(dst)
|
| 91 |
+
cols.append(src)
|
| 92 |
+
data.append(-np.ones(n, dtype=np.float32))
|
| 93 |
+
r = np.concatenate(rows)
|
| 94 |
+
c = np.concatenate(cols)
|
| 95 |
+
d = np.concatenate(data)
|
| 96 |
+
# This is a graph Laplacian L in vertex-index form; ``LtL`` == L (since L is
|
| 97 |
+
# symmetric PSD). Return it as the penalty operator directly.
|
| 98 |
+
return coo_matrix((d, (r, c)), shape=(V, V)).tocsr()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def estimate_lut(
|
| 102 |
+
before,
|
| 103 |
+
after,
|
| 104 |
+
size: int = 33,
|
| 105 |
+
lam_smooth: float = 0.05,
|
| 106 |
+
lam_anchor: float = 1e-5,
|
| 107 |
+
device=None,
|
| 108 |
+
):
|
| 109 |
+
"""Estimate a size^3 3D LUT mapping ``before`` colors to ``after`` colors.
|
| 110 |
+
|
| 111 |
+
Trilinear-consistent sparse least squares with a **Laplacian smoothness
|
| 112 |
+
prior** and a tiny identity anchor to break gauge invariance:
|
| 113 |
+
|
| 114 |
+
min_L ||W L - after||^2
|
| 115 |
+
+ lam_smooth * L^T Δ L (3D grid smoothness)
|
| 116 |
+
+ lam_anchor * ||L - identity||^2 (gauge fix)
|
| 117 |
+
|
| 118 |
+
The smoothness prior is the key fix for tone-migration artifacts (e.g.
|
| 119 |
+
saturated colors like lips): sparsely-covered LUT cells extrapolate from
|
| 120 |
+
neighboring *covered* cells that carry the correct color transform,
|
| 121 |
+
instead of being pulled back toward the input color by an identity anchor.
|
| 122 |
+
|
| 123 |
+
``before`` / ``after`` may be HxWx3 arrays or flat (N, 3) arrays with the
|
| 124 |
+
same length. ``device`` picks the solver backend:
|
| 125 |
+
- None / "cpu": scipy sparse direct solve (spsolve).
|
| 126 |
+
- "cuda" / torch.device("cuda"): torch sparse block-CG on GPU (~90x
|
| 127 |
+
faster on hi-res pairs).
|
| 128 |
+
Falls back to nearest-cell averaging if SciPy is unavailable on CPU path.
|
| 129 |
+
"""
|
| 130 |
+
if device is not None and str(device) != "cpu":
|
| 131 |
+
return _estimate_lut_gpu(
|
| 132 |
+
before, after, size, lam_smooth, lam_anchor, device=device
|
| 133 |
+
)
|
| 134 |
+
return _estimate_lut_cpu(before, after, size, lam_smooth, lam_anchor)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _estimate_lut_cpu(before, after, size, lam_smooth, lam_anchor):
|
| 138 |
+
before = np.asarray(before, dtype=np.float32).reshape(-1, 3)
|
| 139 |
+
after = np.asarray(after, dtype=np.float32).reshape(-1, 3)
|
| 140 |
+
assert before.shape == after.shape, "before/after must have same shape"
|
| 141 |
+
|
| 142 |
+
V = size ** 3
|
| 143 |
+
|
| 144 |
+
# Identity LUT anchor (used by both the LS solve and the fallback).
|
| 145 |
+
grid = np.linspace(0, 255, size, dtype=np.float32)
|
| 146 |
+
R, G, B = np.meshgrid(grid, grid, grid, indexing='ij')
|
| 147 |
+
identity_flat = np.stack([R, G, B], axis=-1).reshape(V, 3)
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
from scipy.sparse import coo_matrix, eye as sp_eye
|
| 151 |
+
from scipy.sparse.linalg import spsolve
|
| 152 |
+
except ImportError:
|
| 153 |
+
return _estimate_lut_nearest_fallback(
|
| 154 |
+
before, after, size, identity_flat
|
| 155 |
+
).reshape(size, size, size, 3)
|
| 156 |
+
|
| 157 |
+
# Continuous LUT-grid coordinates per pixel.
|
| 158 |
+
pos = before / 255.0 * (size - 1)
|
| 159 |
+
i0 = np.clip(np.floor(pos).astype(np.int64), 0, size - 1)
|
| 160 |
+
i1 = np.clip(i0 + 1, 0, size - 1)
|
| 161 |
+
f = pos - i0
|
| 162 |
+
fr, fg, fb = f[:, 0], f[:, 1], f[:, 2]
|
| 163 |
+
ofr, ofg, ofb = 1.0 - fr, 1.0 - fg, 1.0 - fb
|
| 164 |
+
r0, g0, b0 = i0[:, 0], i0[:, 1], i0[:, 2]
|
| 165 |
+
r1, g1, b1 = i1[:, 0], i1[:, 1], i1[:, 2]
|
| 166 |
+
|
| 167 |
+
def _vidx(r, g, b):
|
| 168 |
+
return (r * size + g) * size + b
|
| 169 |
+
|
| 170 |
+
verts = np.stack([
|
| 171 |
+
_vidx(r0, g0, b0), _vidx(r0, g0, b1),
|
| 172 |
+
_vidx(r0, g1, b0), _vidx(r0, g1, b1),
|
| 173 |
+
_vidx(r1, g0, b0), _vidx(r1, g0, b1),
|
| 174 |
+
_vidx(r1, g1, b0), _vidx(r1, g1, b1),
|
| 175 |
+
], axis=1)
|
| 176 |
+
wts = np.stack([
|
| 177 |
+
ofr * ofg * ofb, ofr * ofg * fb,
|
| 178 |
+
ofr * fg * ofb, ofr * fg * fb,
|
| 179 |
+
fr * ofg * ofb, fr * ofg * fb,
|
| 180 |
+
fr * fg * ofb, fr * fg * fb,
|
| 181 |
+
], axis=1).astype(np.float32)
|
| 182 |
+
|
| 183 |
+
N = before.shape[0]
|
| 184 |
+
rows = np.repeat(np.arange(N, dtype=np.int64), 8)
|
| 185 |
+
cols = verts.reshape(-1)
|
| 186 |
+
data = wts.reshape(-1)
|
| 187 |
+
W = coo_matrix((data, (rows, cols)), shape=(N, V)).tocsr()
|
| 188 |
+
|
| 189 |
+
WtW = (W.T @ W).tocsc()
|
| 190 |
+
scale = max(float(WtW.diagonal().mean()), 1.0)
|
| 191 |
+
lap = _build_lut_laplacian(size).tocsc()
|
| 192 |
+
A = (WtW + (lam_smooth * scale) * lap + lam_anchor * sp_eye(V, format='csc')).tocsc()
|
| 193 |
+
rhs = W.T @ after + lam_anchor * identity_flat
|
| 194 |
+
|
| 195 |
+
lut_flat = np.empty((V, 3), dtype=np.float32)
|
| 196 |
+
for c in range(3):
|
| 197 |
+
lut_flat[:, c] = spsolve(A, rhs[:, c])
|
| 198 |
+
|
| 199 |
+
return lut_flat.reshape(size, size, size, 3)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _estimate_lut_gpu(
|
| 203 |
+
before, after, size, lam_smooth, lam_anchor, device,
|
| 204 |
+
tol: float = 1e-4, max_iter: int = 200,
|
| 205 |
+
):
|
| 206 |
+
"""GPU LS solve via preconditioned block conjugate gradient.
|
| 207 |
+
|
| 208 |
+
Solves the same system as ``_estimate_lut_cpu`` but on the specified CUDA
|
| 209 |
+
device using ``torch.sparse.mm`` for matvecs. Returns a numpy array shaped
|
| 210 |
+
``(size, size, size, 3)`` matching the CPU path.
|
| 211 |
+
"""
|
| 212 |
+
b = torch.as_tensor(before, dtype=torch.float32, device=device).reshape(-1, 3)
|
| 213 |
+
y = torch.as_tensor(after, dtype=torch.float32, device=device).reshape(-1, 3)
|
| 214 |
+
N = b.shape[0]
|
| 215 |
+
V = size ** 3
|
| 216 |
+
|
| 217 |
+
pos = b / 255.0 * (size - 1)
|
| 218 |
+
i0 = pos.floor().clamp(0, size - 1).long()
|
| 219 |
+
i1 = (i0 + 1).clamp(0, size - 1)
|
| 220 |
+
f = pos - i0.float()
|
| 221 |
+
fr, fg, fb = f[:, 0], f[:, 1], f[:, 2]
|
| 222 |
+
ofr, ofg, ofb = 1 - fr, 1 - fg, 1 - fb
|
| 223 |
+
r0, g0, b0 = i0[:, 0], i0[:, 1], i0[:, 2]
|
| 224 |
+
r1, g1, b1 = i1[:, 0], i1[:, 1], i1[:, 2]
|
| 225 |
+
|
| 226 |
+
def _vidx(r, g, c):
|
| 227 |
+
return (r * size + g) * size + c
|
| 228 |
+
|
| 229 |
+
verts = torch.stack([
|
| 230 |
+
_vidx(r0, g0, b0), _vidx(r0, g0, b1),
|
| 231 |
+
_vidx(r0, g1, b0), _vidx(r0, g1, b1),
|
| 232 |
+
_vidx(r1, g0, b0), _vidx(r1, g0, b1),
|
| 233 |
+
_vidx(r1, g1, b0), _vidx(r1, g1, b1),
|
| 234 |
+
], dim=1)
|
| 235 |
+
wts = torch.stack([
|
| 236 |
+
ofr * ofg * ofb, ofr * ofg * fb,
|
| 237 |
+
ofr * fg * ofb, ofr * fg * fb,
|
| 238 |
+
fr * ofg * ofb, fr * ofg * fb,
|
| 239 |
+
fr * fg * ofb, fr * fg * fb,
|
| 240 |
+
], dim=1)
|
| 241 |
+
|
| 242 |
+
rows = torch.arange(N, device=device).repeat_interleave(8)
|
| 243 |
+
cols = verts.reshape(-1)
|
| 244 |
+
vals = wts.reshape(-1)
|
| 245 |
+
W = torch.sparse_coo_tensor(torch.stack([rows, cols]), vals, (N, V)).coalesce()
|
| 246 |
+
Wt = W.transpose(0, 1).coalesce()
|
| 247 |
+
|
| 248 |
+
# 3D graph Laplacian on the LUT grid.
|
| 249 |
+
idx3 = torch.arange(V, device=device).view(size, size, size)
|
| 250 |
+
L_rows: list[torch.Tensor] = []
|
| 251 |
+
L_cols: list[torch.Tensor] = []
|
| 252 |
+
L_vals: list[torch.Tensor] = []
|
| 253 |
+
for ax in range(3):
|
| 254 |
+
sl = [slice(None)] * 3
|
| 255 |
+
sl[ax] = slice(None, -1)
|
| 256 |
+
src = idx3[tuple(sl)].reshape(-1)
|
| 257 |
+
sl[ax] = slice(1, None)
|
| 258 |
+
dst = idx3[tuple(sl)].reshape(-1)
|
| 259 |
+
ones_s = torch.ones_like(src, dtype=torch.float32)
|
| 260 |
+
ones_d = torch.ones_like(dst, dtype=torch.float32)
|
| 261 |
+
L_rows += [src, dst, src, dst]
|
| 262 |
+
L_cols += [src, dst, dst, src]
|
| 263 |
+
L_vals += [ones_s, ones_d, -ones_s, -ones_d]
|
| 264 |
+
Lop = torch.sparse_coo_tensor(
|
| 265 |
+
torch.stack([torch.cat(L_rows), torch.cat(L_cols)]),
|
| 266 |
+
torch.cat(L_vals), (V, V)).coalesce()
|
| 267 |
+
|
| 268 |
+
# WᵀW diag + Laplacian diag → Jacobi preconditioner.
|
| 269 |
+
WtW_diag = torch.zeros(V, device=device).scatter_add_(
|
| 270 |
+
0, verts.reshape(-1), wts.reshape(-1) ** 2)
|
| 271 |
+
scale = max(float(WtW_diag.mean().item()), 1.0)
|
| 272 |
+
lam_s = lam_smooth * scale
|
| 273 |
+
lam_a = lam_anchor
|
| 274 |
+
L_diag = torch.zeros(V, device=device)
|
| 275 |
+
same = Lop.indices()[0] == Lop.indices()[1]
|
| 276 |
+
L_diag.scatter_add_(0, Lop.indices()[0][same], Lop.values()[same])
|
| 277 |
+
Minv = 1.0 / (WtW_diag + lam_s * L_diag + lam_a)
|
| 278 |
+
|
| 279 |
+
grid = torch.linspace(0, 255, size, device=device)
|
| 280 |
+
R, G, B = torch.meshgrid(grid, grid, grid, indexing='ij')
|
| 281 |
+
identity = torch.stack([R, G, B], dim=-1).reshape(V, 3)
|
| 282 |
+
|
| 283 |
+
def A_matvec(X):
|
| 284 |
+
return (torch.sparse.mm(Wt, torch.sparse.mm(W, X))
|
| 285 |
+
+ lam_s * torch.sparse.mm(Lop, X)
|
| 286 |
+
+ lam_a * X)
|
| 287 |
+
|
| 288 |
+
rhs = torch.sparse.mm(Wt, y) + lam_a * identity
|
| 289 |
+
|
| 290 |
+
# Preconditioned block Conjugate Gradient (per-channel in parallel).
|
| 291 |
+
X = torch.zeros_like(rhs)
|
| 292 |
+
R_ = rhs - A_matvec(X)
|
| 293 |
+
Z = Minv.unsqueeze(1) * R_
|
| 294 |
+
P = Z.clone()
|
| 295 |
+
rz_old = (R_ * Z).sum(dim=0)
|
| 296 |
+
b_norm = rhs.norm(dim=0).clamp_min(1e-30)
|
| 297 |
+
for _ in range(max_iter):
|
| 298 |
+
AP = A_matvec(P)
|
| 299 |
+
alpha = rz_old / ((P * AP).sum(dim=0) + 1e-30)
|
| 300 |
+
X = X + alpha.unsqueeze(0) * P
|
| 301 |
+
R_ = R_ - alpha.unsqueeze(0) * AP
|
| 302 |
+
if (R_.norm(dim=0) / b_norm).max().item() < tol:
|
| 303 |
+
break
|
| 304 |
+
Z = Minv.unsqueeze(1) * R_
|
| 305 |
+
rz_new = (R_ * Z).sum(dim=0)
|
| 306 |
+
P = Z + (rz_new / rz_old).unsqueeze(0) * P
|
| 307 |
+
rz_old = rz_new
|
| 308 |
+
|
| 309 |
+
return X.reshape(size, size, size, 3).detach().cpu().numpy()
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _estimate_lut_nearest_fallback(before, after, size, identity_flat):
|
| 313 |
+
"""Old nearest-cell averaging path, used only if SciPy is missing."""
|
| 314 |
+
idx = np.clip(
|
| 315 |
+
np.round(before / 255.0 * (size - 1)).astype(np.int32),
|
| 316 |
+
0, size - 1)
|
| 317 |
+
flat = (idx[:, 0] * size + idx[:, 1]) * size + idx[:, 2]
|
| 318 |
+
|
| 319 |
+
V = size ** 3
|
| 320 |
+
lut = np.zeros((V, 3), dtype=np.float32)
|
| 321 |
+
counts = np.zeros(V, dtype=np.int64)
|
| 322 |
+
np.add.at(lut, flat, after)
|
| 323 |
+
np.add.at(counts, flat, 1)
|
| 324 |
+
filled = counts > 0
|
| 325 |
+
lut[filled] /= counts[filled, None]
|
| 326 |
+
lut[~filled] = identity_flat[~filled]
|
| 327 |
+
return lut
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def apply_lut(content, lut, device=None):
|
| 331 |
+
"""Apply a 3D LUT to an RGB image (any resolution) with trilinear interp.
|
| 332 |
+
|
| 333 |
+
``device=None`` runs the numpy path (portable); a CUDA device runs it via
|
| 334 |
+
``torch.nn.functional.grid_sample`` (~50x faster on hi-res images).
|
| 335 |
+
"""
|
| 336 |
+
if device is not None and str(device) != "cpu":
|
| 337 |
+
return _apply_lut_gpu(content, lut, device)
|
| 338 |
+
return _apply_lut_cpu(content, lut)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def _apply_lut_cpu(content, lut):
|
| 342 |
+
size = lut.shape[0]
|
| 343 |
+
img = np.asarray(content, dtype=np.float32) / 255.0 * (size - 1)
|
| 344 |
+
|
| 345 |
+
i0 = np.floor(img).astype(np.int32)
|
| 346 |
+
i0 = np.clip(i0, 0, size - 1)
|
| 347 |
+
i1 = np.clip(i0 + 1, 0, size - 1)
|
| 348 |
+
f = img - i0
|
| 349 |
+
|
| 350 |
+
r0, g0, b0 = i0[..., 0], i0[..., 1], i0[..., 2]
|
| 351 |
+
r1, g1, b1 = i1[..., 0], i1[..., 1], i1[..., 2]
|
| 352 |
+
fr = f[..., 0:1]
|
| 353 |
+
fg = f[..., 1:2]
|
| 354 |
+
fb = f[..., 2:3]
|
| 355 |
+
|
| 356 |
+
c000 = lut[r0, g0, b0]
|
| 357 |
+
c001 = lut[r0, g0, b1]
|
| 358 |
+
c010 = lut[r0, g1, b0]
|
| 359 |
+
c011 = lut[r0, g1, b1]
|
| 360 |
+
c100 = lut[r1, g0, b0]
|
| 361 |
+
c101 = lut[r1, g0, b1]
|
| 362 |
+
c110 = lut[r1, g1, b0]
|
| 363 |
+
c111 = lut[r1, g1, b1]
|
| 364 |
+
|
| 365 |
+
c00 = c000 * (1 - fb) + c001 * fb
|
| 366 |
+
c01 = c010 * (1 - fb) + c011 * fb
|
| 367 |
+
c10 = c100 * (1 - fb) + c101 * fb
|
| 368 |
+
c11 = c110 * (1 - fb) + c111 * fb
|
| 369 |
+
c0 = c00 * (1 - fg) + c01 * fg
|
| 370 |
+
c1 = c10 * (1 - fg) + c11 * fg
|
| 371 |
+
out = c0 * (1 - fr) + c1 * fr
|
| 372 |
+
|
| 373 |
+
return np.clip(out, 0, 255).astype(np.uint8)
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def _apply_lut_gpu(content, lut, device):
|
| 377 |
+
"""GPU trilinear LUT application via ``F.grid_sample`` (5D volume)."""
|
| 378 |
+
import torch.nn.functional as F
|
| 379 |
+
size = lut.shape[0]
|
| 380 |
+
lut_np = np.asarray(lut, dtype=np.float32)
|
| 381 |
+
lut_t = torch.from_numpy(lut_np).permute(3, 0, 1, 2).unsqueeze(0).to(device)
|
| 382 |
+
# LUT dim layout after permute: (N=1, C=3, D=R, H=G, W=B).
|
| 383 |
+
|
| 384 |
+
img = torch.from_numpy(np.asarray(content, dtype=np.float32)).to(device)
|
| 385 |
+
coord = img / 255.0 * (size - 1)
|
| 386 |
+
coord = 2 * coord / (size - 1) - 1 # → [-1, 1] w.r.t. (R, G, B)
|
| 387 |
+
# grid_sample expects last-dim order (x, y, z) == (W, H, D) == (B, G, R).
|
| 388 |
+
coord = coord[..., [2, 1, 0]].unsqueeze(0).unsqueeze(0) # (1, 1, H, W, 3)
|
| 389 |
+
out = F.grid_sample(
|
| 390 |
+
lut_t, coord, mode="bilinear", align_corners=True, padding_mode="border"
|
| 391 |
+
)
|
| 392 |
+
out = out.squeeze(0).squeeze(1).permute(1, 2, 0)
|
| 393 |
+
return out.clamp(0, 255).byte().cpu().numpy()
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def build_triptych(
|
| 397 |
+
content: Image.Image, reference: Image.Image, panel_w: int, panel_h: int
|
| 398 |
+
):
|
| 399 |
+
"""Return (triptych_pil, mask_pil) both at (3*panel_w, panel_h).
|
| 400 |
+
|
| 401 |
+
- Triptych: ``[content | reference | content]`` — the right third is a
|
| 402 |
+
copy of content, acting as an identity prior for prepare_latents
|
| 403 |
+
(matches the training/validation convention).
|
| 404 |
+
- Mask: L-mode PIL, 0 elsewhere and 255 on the right third (fill region).
|
| 405 |
+
"""
|
| 406 |
+
canvas_w = panel_w * 3
|
| 407 |
+
canvas_h = panel_h
|
| 408 |
+
|
| 409 |
+
tri = Image.new("RGB", (canvas_w, canvas_h))
|
| 410 |
+
tri.paste(content, (0, 0))
|
| 411 |
+
tri.paste(reference, (panel_w, 0))
|
| 412 |
+
tri.paste(content, (2 * panel_w, 0))
|
| 413 |
+
|
| 414 |
+
mask_arr = np.zeros((canvas_h, canvas_w), dtype=np.uint8)
|
| 415 |
+
mask_arr[:, 2 * panel_w:] = 255
|
| 416 |
+
mask = Image.fromarray(mask_arr, mode="L")
|
| 417 |
+
return tri, mask
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def run_one(
|
| 421 |
+
pipe: FluxFillPipeline,
|
| 422 |
+
content_pil: Image.Image,
|
| 423 |
+
reference_pil: Image.Image,
|
| 424 |
+
*,
|
| 425 |
+
size: int,
|
| 426 |
+
prompt: str,
|
| 427 |
+
guidance_scale: float,
|
| 428 |
+
num_inference_steps: int,
|
| 429 |
+
seed: int,
|
| 430 |
+
generator_device: str,
|
| 431 |
+
) -> tuple[Image.Image, Image.Image, Image.Image, Image.Image]:
|
| 432 |
+
"""Run one triptych inference. Returns (pred_pil, panel_pil, tri_pil, content_sq).
|
| 433 |
+
|
| 434 |
+
- ``pred_pil``: just the right-third region — the migrated result.
|
| 435 |
+
- ``panel_pil``: horizontal panel ``[content | reference | pred]``.
|
| 436 |
+
- ``tri_pil``: the full pipeline output. Useful for debugging.
|
| 437 |
+
- ``content_sq``: the resized content actually fed to the pipeline. Kept
|
| 438 |
+
so callers can pair it with ``pred_pil`` for LUT fitting.
|
| 439 |
+
|
| 440 |
+
Content's width is resized to ``size`` while its height keeps the original
|
| 441 |
+
aspect ratio (rounded to a multiple of 16 for the VAE). Reference is
|
| 442 |
+
force-resized to the same ``(size, H)`` so the triptych panels line up.
|
| 443 |
+
Canvas is ``3*size x H``.
|
| 444 |
+
"""
|
| 445 |
+
content = _resize_width_keep_aspect(content_pil, size, height_multiple=16)
|
| 446 |
+
panel_w, panel_h = content.size
|
| 447 |
+
# Stretch reference directly to content's (W, H) — no aspect preservation,
|
| 448 |
+
# no cropping. Panels line up by construction.
|
| 449 |
+
if reference_pil.mode != "RGB":
|
| 450 |
+
reference_pil = reference_pil.convert("RGB")
|
| 451 |
+
reference = reference_pil.resize((panel_w, panel_h), Image.LANCZOS)
|
| 452 |
+
|
| 453 |
+
tri, mask = build_triptych(content, reference, panel_w, panel_h)
|
| 454 |
+
|
| 455 |
+
generator = torch.Generator(device=generator_device).manual_seed(seed)
|
| 456 |
+
result = pipe(
|
| 457 |
+
prompt=prompt,
|
| 458 |
+
image=tri,
|
| 459 |
+
mask_image=mask,
|
| 460 |
+
height=panel_h,
|
| 461 |
+
width=panel_w * 3,
|
| 462 |
+
guidance_scale=guidance_scale,
|
| 463 |
+
num_inference_steps=num_inference_steps,
|
| 464 |
+
max_sequence_length=512,
|
| 465 |
+
generator=generator,
|
| 466 |
+
).images[0]
|
| 467 |
+
|
| 468 |
+
pred = result.crop((panel_w * 2, 0, panel_w * 3, panel_h))
|
| 469 |
+
panel = Image.new("RGB", (panel_w * 3, panel_h))
|
| 470 |
+
panel.paste(content, (0, 0))
|
| 471 |
+
panel.paste(reference, (panel_w, 0))
|
| 472 |
+
panel.paste(pred, (panel_w * 2, 0))
|
| 473 |
+
return pred, panel, result, content
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def main():
|
| 477 |
+
parser = argparse.ArgumentParser("Zero-shot tone migration with FLUX.1-Fill")
|
| 478 |
+
# Single-pair inputs
|
| 479 |
+
parser.add_argument("--content", type=str, default=None,
|
| 480 |
+
help="Path to the content (source) image.")
|
| 481 |
+
parser.add_argument("--reference", type=str, default=None,
|
| 482 |
+
help="Path to the reference (style/tone) image.")
|
| 483 |
+
# Batch mode over TST2K
|
| 484 |
+
parser.add_argument("--tst2k-dir", type=str, default=None,
|
| 485 |
+
help="Root of TST2K-style eval set. Each subdir must contain "
|
| 486 |
+
"content.png + reference.png. If set, --content/--reference "
|
| 487 |
+
"are ignored and up to --tst2k-num subdirs are processed.")
|
| 488 |
+
parser.add_argument("--triplet-list", type=str, default=None,
|
| 489 |
+
help="Text file with 3 whitespace-separated columns per row: "
|
| 490 |
+
"<content_path> <reference_path> <gt_path>. Comment lines "
|
| 491 |
+
"starting with '#' are skipped. Overrides --tst2k-dir and "
|
| 492 |
+
"--content/--reference. Row index (0-based) becomes the "
|
| 493 |
+
"output stem (NNNN.png).")
|
| 494 |
+
parser.add_argument("--tst2k-num", type=int, default=50,
|
| 495 |
+
help="Max number of samples (subdirs or list rows) to iterate.")
|
| 496 |
+
|
| 497 |
+
parser.add_argument("--output-dir", type=str, default="./tone_out",
|
| 498 |
+
help="Directory for batch outputs. Ignored when --output-file is set.")
|
| 499 |
+
parser.add_argument("--output-file", type=str, default=None,
|
| 500 |
+
help="Exact output image path for single-pair inference. "
|
| 501 |
+
"When set, --output-dir is not used.")
|
| 502 |
+
parser.add_argument("--flux-path", type=str,
|
| 503 |
+
default="ckpt/FLUX-Fill")
|
| 504 |
+
parser.add_argument("--lora-path", type=str, default=None,
|
| 505 |
+
help="Optional LoRA path (dir or .safetensors). Omit to test "
|
| 506 |
+
"the base FluxFill model with no fine-tuning.")
|
| 507 |
+
|
| 508 |
+
parser.add_argument("--image-size", type=int, default=512,
|
| 509 |
+
help="Content width; height keeps the source aspect "
|
| 510 |
+
"ratio (rounded to a multiple of 16 for the VAE). "
|
| 511 |
+
"Canvas width = 3 * this.")
|
| 512 |
+
parser.add_argument("--lut-size", type=int, default=33,
|
| 513 |
+
help="3D LUT grid size per channel used to lift the "
|
| 514 |
+
"512-res migration back onto the original hi-res "
|
| 515 |
+
"content. Set to 0 to skip hi-res reconstruction.")
|
| 516 |
+
parser.add_argument("--lut-device", type=str, default="auto",
|
| 517 |
+
choices=["auto", "cpu", "cuda"],
|
| 518 |
+
help="Solver device for LUT estimate + apply. 'auto' "
|
| 519 |
+
"uses CUDA when available (~90x faster estimate, "
|
| 520 |
+
"~50x faster apply on hi-res images).")
|
| 521 |
+
parser.add_argument("--num-inference-steps", type=int, default=28)
|
| 522 |
+
parser.add_argument("--guidance-scale", type=float, default=30.0,
|
| 523 |
+
help="FluxFill's guidance-distilled embed value. ICEdit's "
|
| 524 |
+
"reference inference.py uses 50; 30 is FluxFill default.")
|
| 525 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 526 |
+
parser.add_argument("--prompt", type=str, default=None,
|
| 527 |
+
help="Text prompt. Omit to use the built-in triptych "
|
| 528 |
+
"instruction (identical to the training default).")
|
| 529 |
+
|
| 530 |
+
parser.add_argument("--enable-model-cpu-offload", action="store_true")
|
| 531 |
+
parser.add_argument("--dtype", type=str, default="bfloat16",
|
| 532 |
+
choices=["bfloat16", "float16", "float32"])
|
| 533 |
+
parser.add_argument("--generator-device", type=str, default="cpu",
|
| 534 |
+
choices=["cpu", "cuda"],
|
| 535 |
+
help="Where the noise generator lives. ICEdit uses 'cpu'; "
|
| 536 |
+
"'cuda' silences the diffusers 'passed generator was "
|
| 537 |
+
"created on cpu' warning at the cost of slightly "
|
| 538 |
+
"different bit-exact noise across restarts.")
|
| 539 |
+
|
| 540 |
+
# Data-parallel sharding across independent processes (one per GPU).
|
| 541 |
+
# Each shard iterates the same global job list but only processes indices
|
| 542 |
+
# ``i`` where ``i % num_shards == shard_index``. The per-sample seed is
|
| 543 |
+
# derived from the *global* index so results are identical to a
|
| 544 |
+
# single-process run.
|
| 545 |
+
parser.add_argument("--num-shards", type=int, default=1,
|
| 546 |
+
help="Total number of parallel shards (processes).")
|
| 547 |
+
parser.add_argument("--shard-index", type=int, default=0,
|
| 548 |
+
help="This shard's index in [0, num_shards).")
|
| 549 |
+
|
| 550 |
+
args = parser.parse_args()
|
| 551 |
+
|
| 552 |
+
if args.triplet_list is None and args.tst2k_dir is None \
|
| 553 |
+
and (args.content is None or args.reference is None):
|
| 554 |
+
parser.error(
|
| 555 |
+
"Provide one of: --triplet-list, --tst2k-dir, or both --content and --reference."
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
if args.num_shards < 1 or not (0 <= args.shard_index < args.num_shards):
|
| 559 |
+
parser.error(
|
| 560 |
+
f"Invalid sharding: shard_index={args.shard_index}, "
|
| 561 |
+
f"num_shards={args.num_shards}."
|
| 562 |
+
)
|
| 563 |
+
|
| 564 |
+
if args.output_file is not None and (
|
| 565 |
+
args.triplet_list is not None or args.tst2k_dir is not None
|
| 566 |
+
):
|
| 567 |
+
parser.error("--output-file is only supported for single-pair inference.")
|
| 568 |
+
|
| 569 |
+
torch_dtype = {
|
| 570 |
+
"bfloat16": torch.bfloat16,
|
| 571 |
+
"float16": torch.float16,
|
| 572 |
+
"float32": torch.float32,
|
| 573 |
+
}[args.dtype]
|
| 574 |
+
|
| 575 |
+
print(f"[load] FluxFill from {args.flux_path} dtype={args.dtype}")
|
| 576 |
+
pipe = FluxFillPipeline.from_pretrained(args.flux_path, torch_dtype=torch_dtype)
|
| 577 |
+
|
| 578 |
+
if args.lora_path:
|
| 579 |
+
print(f"[load] LoRA weights from {args.lora_path}")
|
| 580 |
+
pipe.load_lora_weights(args.lora_path)
|
| 581 |
+
else:
|
| 582 |
+
print("[load] no LoRA — testing base FluxFill zero-shot")
|
| 583 |
+
|
| 584 |
+
if args.enable_model_cpu_offload:
|
| 585 |
+
pipe.enable_model_cpu_offload()
|
| 586 |
+
else:
|
| 587 |
+
pipe = pipe.to("cuda")
|
| 588 |
+
|
| 589 |
+
prompt = args.prompt if args.prompt is not None else DEFAULT_INSTANCE_PROMPT
|
| 590 |
+
print(f"[prompt] {prompt}")
|
| 591 |
+
|
| 592 |
+
if args.lut_device == "auto":
|
| 593 |
+
lut_device = "cuda" if torch.cuda.is_available() else None
|
| 594 |
+
elif args.lut_device == "cuda":
|
| 595 |
+
lut_device = "cuda"
|
| 596 |
+
else:
|
| 597 |
+
lut_device = None
|
| 598 |
+
print(f"[lut] solver device: {lut_device or 'cpu'}")
|
| 599 |
+
|
| 600 |
+
output_file = Path(args.output_file) if args.output_file is not None else None
|
| 601 |
+
if output_file is not None:
|
| 602 |
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
| 603 |
+
dir_out = str(output_file.parent)
|
| 604 |
+
else:
|
| 605 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 606 |
+
dir_out = os.path.join(args.output_dir, "outputs")
|
| 607 |
+
os.makedirs(dir_out, exist_ok=True)
|
| 608 |
+
S = int(args.image_size)
|
| 609 |
+
|
| 610 |
+
# ---- Assemble list of (stem, content_path, reference_path, gt_path) ----
|
| 611 |
+
jobs: list[tuple[str, Path, Path, Path | None]] = []
|
| 612 |
+
if args.triplet_list is not None:
|
| 613 |
+
list_path = Path(args.triplet_list)
|
| 614 |
+
list_base = list_path.resolve().parent
|
| 615 |
+
|
| 616 |
+
def resolve_list_path(value: str) -> Path:
|
| 617 |
+
path = Path(value)
|
| 618 |
+
return path if path.is_absolute() else list_base / path
|
| 619 |
+
|
| 620 |
+
with open(list_path) as f:
|
| 621 |
+
for raw in f:
|
| 622 |
+
line = raw.strip()
|
| 623 |
+
if not line or line.startswith("#"):
|
| 624 |
+
continue
|
| 625 |
+
parts = line.split()
|
| 626 |
+
if len(parts) < 2:
|
| 627 |
+
continue
|
| 628 |
+
c = resolve_list_path(parts[0])
|
| 629 |
+
r = resolve_list_path(parts[1])
|
| 630 |
+
g = resolve_list_path(parts[2]) if len(parts) >= 3 else None
|
| 631 |
+
if not (c.exists() and r.exists()):
|
| 632 |
+
continue
|
| 633 |
+
if g is not None and not g.exists():
|
| 634 |
+
g = None
|
| 635 |
+
stem = f"{len(jobs):04d}"
|
| 636 |
+
jobs.append((stem, c, r, g))
|
| 637 |
+
if len(jobs) >= args.tst2k_num:
|
| 638 |
+
break
|
| 639 |
+
if not jobs:
|
| 640 |
+
raise SystemExit(f"[error] no valid rows in {list_path}")
|
| 641 |
+
print(f"[batch] {len(jobs)} samples from {list_path}")
|
| 642 |
+
elif args.tst2k_dir is not None:
|
| 643 |
+
root = Path(args.tst2k_dir)
|
| 644 |
+
subs = sorted([p for p in root.iterdir() if p.is_dir()])
|
| 645 |
+
for sub in subs:
|
| 646 |
+
c = sub / "content.png"
|
| 647 |
+
r = sub / "reference.png"
|
| 648 |
+
if not (c.exists() and r.exists()):
|
| 649 |
+
continue
|
| 650 |
+
g = sub / "gt.png"
|
| 651 |
+
jobs.append((sub.name, c, r, g if g.exists() else None))
|
| 652 |
+
if len(jobs) >= args.tst2k_num:
|
| 653 |
+
break
|
| 654 |
+
if not jobs:
|
| 655 |
+
raise SystemExit(f"[error] no valid subdirs found under {root}")
|
| 656 |
+
print(f"[batch] {len(jobs)} samples from {root}")
|
| 657 |
+
else:
|
| 658 |
+
c = Path(args.content)
|
| 659 |
+
r = Path(args.reference)
|
| 660 |
+
jobs.append((c.stem, c, r, None))
|
| 661 |
+
|
| 662 |
+
# ---- Filter jobs for this shard while keeping the global index ----
|
| 663 |
+
# ``global_i`` is the index into the full (unsharded) job list. It drives
|
| 664 |
+
# both the per-sample seed (``args.seed + global_i``) and the output file
|
| 665 |
+
# prefix, so different shards write disjoint filenames and any single
|
| 666 |
+
# sample gets the same seed regardless of shard configuration.
|
| 667 |
+
total_jobs = len(jobs)
|
| 668 |
+
sharded = [
|
| 669 |
+
(gi, job) for gi, job in enumerate(jobs)
|
| 670 |
+
if gi % args.num_shards == args.shard_index
|
| 671 |
+
]
|
| 672 |
+
if args.num_shards > 1:
|
| 673 |
+
print(
|
| 674 |
+
f"[shard] {args.shard_index+1}/{args.num_shards}: "
|
| 675 |
+
f"{len(sharded)}/{total_jobs} samples"
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
# ---- Run inference ----
|
| 679 |
+
for local_i, (global_i, (stem, cpath, rpath, gpath)) in enumerate(sharded):
|
| 680 |
+
content_pil = Image.open(cpath).convert("RGB")
|
| 681 |
+
reference_pil = Image.open(rpath).convert("RGB")
|
| 682 |
+
|
| 683 |
+
pred, panel, full, content_sq = run_one(
|
| 684 |
+
pipe,
|
| 685 |
+
content_pil,
|
| 686 |
+
reference_pil,
|
| 687 |
+
size=S,
|
| 688 |
+
prompt=prompt,
|
| 689 |
+
guidance_scale=args.guidance_scale,
|
| 690 |
+
num_inference_steps=args.num_inference_steps,
|
| 691 |
+
seed=args.seed + global_i,
|
| 692 |
+
generator_device=args.generator_device,
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
# ---- Hi-res reconstruction via 3D LUT ----
|
| 696 |
+
# The pipeline works at S=512, but ``content_pil`` is usually
|
| 697 |
+
# higher-resolution. Fit a 3D LUT so its trilinear evaluation on the
|
| 698 |
+
# hi-res content reproduces the diffusion output.
|
| 699 |
+
#
|
| 700 |
+
# Training pair: hi-res content pixels paired with pred bilinearly
|
| 701 |
+
# upsampled to hi-res. This gives the LUT the exact color distribution
|
| 702 |
+
# it will be applied to (important for saturated regions like lips,
|
| 703 |
+
# whose peak reds get lost when content is first downsampled). We
|
| 704 |
+
# subsample to keep the sparse LS problem small.
|
| 705 |
+
pred_hires = None
|
| 706 |
+
if args.lut_size and args.lut_size > 1:
|
| 707 |
+
content_arr = np.asarray(content_pil)
|
| 708 |
+
pred_up = np.asarray(
|
| 709 |
+
pred.resize(content_pil.size, Image.BILINEAR)
|
| 710 |
+
)
|
| 711 |
+
flat_before = content_arr.reshape(-1, 3)
|
| 712 |
+
flat_after = pred_up.reshape(-1, 3)
|
| 713 |
+
max_samples = 500_000
|
| 714 |
+
if flat_before.shape[0] > max_samples:
|
| 715 |
+
rng = np.random.default_rng(0)
|
| 716 |
+
sel = rng.choice(
|
| 717 |
+
flat_before.shape[0], size=max_samples, replace=False
|
| 718 |
+
)
|
| 719 |
+
flat_before = flat_before[sel]
|
| 720 |
+
flat_after = flat_after[sel]
|
| 721 |
+
lut = estimate_lut(
|
| 722 |
+
flat_before,
|
| 723 |
+
flat_after,
|
| 724 |
+
size=int(args.lut_size),
|
| 725 |
+
device=lut_device,
|
| 726 |
+
)
|
| 727 |
+
hires_arr = apply_lut(content_arr, lut, device=lut_device)
|
| 728 |
+
pred_hires = Image.fromarray(hires_arr)
|
| 729 |
+
|
| 730 |
+
# Content-resolution reconstruction goes to ``outputs/``.
|
| 731 |
+
# Filename is just the (1-based) global index, zero-padded to 4 digits,
|
| 732 |
+
# so shards never collide and results sort naturally.
|
| 733 |
+
code = f"{global_i :04d}"
|
| 734 |
+
name_out = str(output_file) if output_file is not None else str(Path(dir_out) / code)
|
| 735 |
+
if pred_hires is not None:
|
| 736 |
+
pred_hires.save(
|
| 737 |
+
str(output_file) if output_file is not None else f"{name_out}.png"
|
| 738 |
+
)
|
| 739 |
+
|
| 740 |
+
print(
|
| 741 |
+
f"[done] shard {args.shard_index+1}/{args.num_shards} "
|
| 742 |
+
f"{local_i+1}/{len(sharded)} (global {global_i+1}/{total_jobs}) "
|
| 743 |
+
f"{stem} → {name_out}.png"
|
| 744 |
+
)
|
| 745 |
+
|
| 746 |
+
print(
|
| 747 |
+
f"[all done] shard {args.shard_index+1}/{args.num_shards} — "
|
| 748 |
+
f"results under {os.path.abspath(args.output_dir)}"
|
| 749 |
+
)
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
if __name__ == "__main__":
|
| 753 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ZeroGPU-compatible inference environment
|
| 2 |
+
torch==2.8.0
|
| 3 |
+
|
| 4 |
+
diffusers==0.39.0
|
| 5 |
+
transformers==4.57.6
|
| 6 |
+
accelerate==1.12.0
|
| 7 |
+
peft==0.18.0
|
| 8 |
+
safetensors==0.8.0
|
| 9 |
+
huggingface-hub==0.36.0
|
| 10 |
+
sentencepiece==0.2.1
|
| 11 |
+
protobuf==7.35.1
|
| 12 |
+
|
| 13 |
+
numpy==2.1.3
|
| 14 |
+
scipy==1.15.2
|
| 15 |
+
Pillow==11.1.0
|
| 16 |
+
|
| 17 |
+
gradio>=4,<7
|
| 18 |
+
spaces
|