Yh777 commited on
Commit
1a25d7e
·
1 Parent(s): 931bf46

update app.pyp

Browse files
Files changed (3) hide show
  1. app.py +269 -4
  2. inference.py +753 -0
  3. 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
- demo = gr.Interface(fn=greet, inputs="text", outputs="text")
7
- demo.launch()
 
 
 
 
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