nimzuk commited on
Commit
e3570a2
·
verified ·
1 Parent(s): f2042e5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +216 -206
app.py CHANGED
@@ -1,223 +1,233 @@
1
  # app.py
2
- import os, time, random
3
  from typing import Tuple
4
- import gradio as gr
5
  import numpy as np
6
- from PIL import Image, ImageFilter, ImageDraw
7
-
8
  import torch
9
- from diffusers import StableDiffusionInpaintPipeline, DPMSolverMultistepScheduler
10
-
11
- # ------------------------------
12
- # Config & model
13
- # ------------------------------
14
- MODEL_ID = os.getenv("MODEL_ID", "stabilityai/stable-diffusion-2-inpainting")
15
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
16
- DTYPE = torch.float16 if DEVICE == "cuda" else torch.float32
17
-
18
- OUT_DIR = "/mnt/data" # оставляем /mnt/data
19
- os.makedirs(OUT_DIR, exist_ok=True)
20
-
21
- print(f"Loading model: {MODEL_ID} (device={DEVICE}, dtype={DTYPE})")
22
- pipe = StableDiffusionInpaintPipeline.from_pretrained(
23
- MODEL_ID,
24
- torch_dtype=DTYPE,
25
- safety_checker=None, # для локального теста
26
- ).to(DEVICE)
27
- # Более «послушный» шагатель, лучше держит NEG и фон
28
- pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
29
- pipe.enable_attention_slicing()
30
- if DEVICE == "cuda":
31
- try:
32
- pipe.enable_model_cpu_offload()
33
- except Exception:
34
- pass
35
-
36
- # ------------------------------
37
- # Helpers
38
- # ------------------------------
39
- def round_to_eight(x: int) -> int:
40
- return int(max(64, (x // 8) * 8))
 
 
 
 
41
 
42
- def make_square_canvas(img: Image.Image, pad_px: int, bg=(200,200,200)) -> Tuple[Image.Image, Tuple[int,int]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  w, h = img.size
44
- base = max(w, h)
45
- W = base + 2*pad_px
46
- H = base + 2*pad_px
47
- canvas = Image.new("RGB", (W, H), bg)
48
- x0 = (W - w)//2
49
- y0 = (H - h)//2
50
- canvas.paste(img, (x0, y0))
51
- return canvas, (x0, y0)
52
-
53
- def make_ring_mask(canvas_size: Tuple[int,int], inner_rect: Tuple[int,int,int,int], feather_px:int) -> Image.Image:
54
- W, H = canvas_size
55
- x0,y0,x1,y1 = inner_rect
56
- mask = Image.new("L", (W,H), 255)
57
- draw = ImageDraw.Draw(mask)
58
- draw.rectangle([x0,y0,x1,y1], fill=0)
59
- if feather_px > 0:
60
- mask = mask.filter(ImageFilter.GaussianBlur(radius=feather_px))
 
 
 
 
 
 
 
 
 
 
 
 
61
  return mask
62
 
63
- def paste_original_back(generated: Image.Image, original: Image.Image, offset: Tuple[int,int]) -> Image.Image:
64
- out = generated.copy()
65
- out.paste(original, offset)
66
- return out
67
-
68
- def preview_frame(image: Image.Image, pad_px:int):
69
- w,h = image.size
70
- base = max(w,h)
71
- W = base + 2*pad_px
72
- H = base + 2*pad_px
73
- canvas, offset = make_square_canvas(image, pad_px)
74
- draw = ImageDraw.Draw(canvas)
75
- x0,y0 = offset
76
- x1,y1 = x0 + w, y0 + h
77
- draw.rectangle([x0-1,y0-1,x1+1,y1+1], outline=(255, 90, 90), width=3)
78
- prev = canvas.copy()
79
- prev.thumbnail((512, 512), Image.LANCZOS)
80
- top = y0
81
- left = x0
82
- right = W - (x0 + w)
83
- bottom = H - (y0 + h)
84
- info = f"Final canvas: {W}×{H}px • pad: {pad_px}px • add: top {top}px, bottom {bottom}px, left {left}px, right {right}px"
85
- return prev, info
86
-
87
- # ------------------------------
88
- # Generation
89
- # ------------------------------
90
- def outpaint_generate(
91
- input_image: Image.Image,
92
- pad_px: int,
93
- prompt: str,
94
- negative_prompt: str,
95
- steps: int,
96
- cfg: float,
97
- feather_px: int,
98
- seed: int,
99
- ):
100
- if input_image is None:
101
- raise gr.Error("Сначала загрузите изображение.")
102
-
103
- canvas, offset = make_square_canvas(input_image.convert("RGB"), pad_px)
104
- w, h = input_image.size
105
- x0, y0 = offset
106
- x1, y1 = x0 + w, y0 + h
107
-
108
- mask = make_ring_mask(canvas.size, (x0,y0,x1,y1), feather_px)
109
-
110
- W = round_to_eight(canvas.size[0])
111
- H = round_to_eight(canvas.size[1])
112
- MAX_SIDE = 1536 if DEVICE == "cuda" else 1024
113
- scale = min(1.0, MAX_SIDE / max(W,H))
114
- if scale < 1.0:
115
- newW = round_to_eight(int(W*scale))
116
- newH = round_to_eight(int(H*scale))
117
- canvas_small = canvas.resize((newW,newH), Image.LANCZOS)
118
- mask_small = mask.resize((newW,newH), Image.LANCZOS)
119
- sx = int(x0*scale); sy = int(y0*scale)
120
- sw = int(w*scale); sh = int(h*scale)
121
- inner_rect_small = (sx,sy,sx+sw,sy+sh)
122
  else:
123
- canvas_small, mask_small = canvas, mask
124
- inner_rect_small = (x0,y0,x1,y1)
125
-
126
- g = torch.Generator(device=DEVICE)
127
- seed_val = random.randint(0, 2**32 - 1) if (seed is None or int(seed) < 0) else int(seed)
128
- g.manual_seed(seed_val)
129
-
130
- with torch.autocast(device_type=DEVICE if DEVICE!="mps" else "cpu"):
131
- out = pipe(
132
- prompt=prompt,
133
- negative_prompt=negative_prompt,
134
- image=canvas_small,
135
- mask_image=mask_small,
136
- guidance_scale=float(cfg),
137
- num_inference_steps=int(steps),
138
- generator=g,
139
- ).images[0]
140
-
141
- if out.size != canvas.size:
142
- out = out.resize(canvas.size, Image.LANCZOS)
143
-
144
- final = paste_original_back(out, input_image.convert("RGB"), offset)
145
-
146
- fname = f"outpaint_{canvas.size[0]}x{canvas.size[1]}_{int(time.time()*1000)}.png"
147
- out_path = os.path.join(OUT_DIR, fname)
148
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
149
- final.save(out_path, "PNG")
150
-
151
- return final, out_path, f"Seed: {seed_val} • Size: {canvas.size[0]}×{canvas.size[1]}"
152
-
153
- # ------------------------------
154
- # Gradio UI
155
- # ------------------------------
156
- DEFAULT_PROMPT = "extend the image naturally, seamless realistic background, consistent lighting, matching style"
157
- DEFAULT_NEG = (
158
- "text, letters, words, caption, typography, logo, watermark, "
159
- "numbers, digits, signboard, poster text, "
160
- "lowres, blurry, artifacts, deformed, distorted, oversaturated, "
161
- "frame, border, mosaic, collage, extra limbs"
162
- )
163
 
164
  with gr.Blocks(css="""
165
- #mini {font-size: 0.9em; opacity: 0.9}
166
- .caption {font-size: 0.9em; color: #aaa}
167
  """) as demo:
168
- gr.Markdown("## Qwen Outpaint (SD2 Inpaint)\nКвадратная дорисовка краёв. Центр сохраняется 1:1. PNG в полном размере.")
169
-
170
  with gr.Row():
171
- with gr.Column(scale=6):
172
- in_img = gr.Image(type="pil", label="Input image", height=560)
173
- pad = gr.Slider(0, 2048, value=256, step=1, label="Padding (px) around square")
174
- feather = gr.Slider(0, 64, value=16, step=1, label="Feather border (px)")
175
- prmpt = gr.Textbox(value=DEFAULT_PROMPT, label="Prompt")
176
- nprmpt = gr.Textbox(value=DEFAULT_NEG, label="Negative prompt")
 
 
 
 
 
 
 
177
  with gr.Row():
178
- steps = gr.Slider(10, 60, value=30, step=1, label="Steps")
179
- cfg = gr.Slider(1.0, 9.0, value=6.5, step=0.5, label="CFG (guidance)")
180
- seed = gr.Number(value=-1, precision=0, label="Seed (-1 = random)")
181
- go_btn = gr.Button("Generate", variant="primary")
182
-
183
- with gr.Column(scale=6):
184
- prev = gr.Image(label="Preview (outpaint region)", height=560)
185
- info = gr.Markdown(elem_id="mini")
186
- with gr.Tab("Result"):
187
- out_img = gr.Image(label="Result", height=560)
188
- meta = gr.Markdown("")
189
- file_out = gr.File(label="Download PNG")
190
-
191
- def _update_preview(img, pad_px):
192
- if img is None:
193
- return None, ""
194
- p, t = preview_frame(img, int(pad_px))
195
- return p, t
196
-
197
- in_img.change(_update_preview, [in_img, pad], [prev, info])
198
- pad.release(_update_preview, [in_img, pad], [prev, info])
199
-
200
- def go(image, pad_px, feather_px, prompt, negative_prompt, steps, cfg, seed):
201
- if image is None:
202
- raise gr.Error("Загрузите изображение.")
203
- res, path, meta_text = outpaint_generate(
204
- image, int(pad_px),
205
- prompt, negative_prompt,
206
- int(steps), float(cfg), int(feather_px), int(seed)
207
- )
208
- return res, meta_text, path
209
-
210
- go_btn.click(
211
- go,
212
- [in_img, pad, feather, prmpt, nprmpt, steps, cfg, seed],
213
- [out_img, meta, file_out]
214
  )
215
 
216
  if __name__ == "__main__":
217
- # КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: разрешаем /mnt/data
218
- demo.launch(
219
- server_name="0.0.0.0",
220
- server_port=7860,
221
- inbrowser=False,
222
- allowed_paths=["/mnt/data"]
223
- )
 
1
  # app.py
2
+ import os, io, math, random, time
3
  from typing import Tuple
 
4
  import numpy as np
5
+ from PIL import Image, ImageFilter, ImageOps
 
6
  import torch
7
+ from diffusers import StableDiffusionXLInpaintPipeline
8
+ import gradio as gr
9
+
10
+ # -----------------------------
11
+ # Settings
12
+ # -----------------------------
13
+ DEFAULT_PROMPT = "extend the image naturally"
14
+ DEFAULT_NEG = (
15
+ "text, letters, words, numbers, caption, watermark, logo, signature, frame, border, "
16
+ "collage tiles, UI elements, artifacts, deformed, duplicate, blurry"
17
+ )
18
+ SAFE_MAX = int(os.getenv("SAFE_MAX", 1792)) # макс. сторона генерации
19
+ HF_TOKEN = os.getenv("HF_TOKEN", None)
20
+
21
+ # -----------------------------
22
+ # Load pipeline (no xformers)
23
+ # -----------------------------
24
+ device = "cuda" if torch.cuda.is_available() else "cpu"
25
+ pipe = StableDiffusionXLInpaintPipeline.from_pretrained(
26
+ "diffusers/stable-diffusion-xl-1.0-inpainting-0.1",
27
+ torch_dtype=torch.float16 if device == "cuda" else torch.float32,
28
+ use_safetensors=True,
29
+ variant="fp16" if device == "cuda" else None,
30
+ token=HF_TOKEN,
31
+ )
32
+ pipe = pipe.to(device)
33
+
34
+ # включаем SDPA/attention slicing вместо xformers
35
+ try:
36
+ if device == "cuda":
37
+ torch.backends.cuda.matmul.allow_tf32 = True
38
+ else:
39
+ pipe.enable_attention_slicing("max")
40
+ except Exception:
41
+ pass
42
+
43
 
44
+ # -----------------------------
45
+ # Helpers
46
+ # -----------------------------
47
+ def _to_pil(img) -> Image.Image:
48
+ return img if isinstance(img, Image.Image) else Image.fromarray(img)
49
+
50
+ def _round64(x: int) -> int:
51
+ return int(math.ceil(x / 64) * 64)
52
+
53
+ def fit_to_safe_max(w: int, h: int, safe_max: int) -> Tuple[int, int, float]:
54
+ """масштабирует (w,h) до safe_max, сохраняя пропорции; возвращает новые w,h и scale"""
55
+ s = 1.0
56
+ max_side = max(w, h)
57
+ if max_side > safe_max:
58
+ s = safe_max / max_side
59
+ w = int(round(w * s))
60
+ h = int(round(h * s))
61
+ # SDXL требует кратность 64
62
+ w = max(512, _round64(w))
63
+ h = max(512, _round64(h))
64
+ return w, h, s
65
+
66
+ def make_square_canvas(img: Image.Image, L: int, R: int, T: int, B: int) -> Tuple[int, int, Tuple[int,int,int,int]]:
67
+ """Считает квадратную сторону и box для вставки оригинала"""
68
  w, h = img.size
69
+ tgt_w = w + L + R
70
+ tgt_h = h + T + B
71
+ side = max(tgt_w, tgt_h)
72
+ # центровка оригинала
73
+ x0 = (side - w) // 2
74
+ y0 = (side - h) // 2
75
+ return side, side, (x0, y0, x0 + w, y0 + h)
76
+
77
+ def build_bg_from_edges(img: Image.Image, side: int) -> Image.Image:
78
+ """Создаёт «умный» фон из повторённых краёв, слегка блюрит."""
79
+ arr = np.asarray(img.convert("RGB"))
80
+ pad_top = (side - img.height) // 2
81
+ pad_bottom = side - img.height - pad_top
82
+ pad_left = (side - img.width) // 2
83
+ pad_right = side - img.width - pad_left
84
+ padded = np.pad(arr, ((pad_top, pad_bottom), (pad_left, pad_right), (0, 0)), mode="edge")
85
+ bg = Image.fromarray(padded, mode="RGB").filter(ImageFilter.GaussianBlur(radius=8))
86
+ return bg
87
+
88
+ def build_mask(side: int, orig_box: Tuple[int,int,int,int], feather: int) -> Image.Image:
89
+ """Белое = генерировать, чёрное = сохраняем оригинал"""
90
+ mask = Image.new("L", (side, side), 255)
91
+ x0, y0, x1, y1 = orig_box
92
+ # чёрный прямоугольник по оригиналу
93
+ ImageDraw = ImageDraw_ensure()
94
+ d = ImageDraw.Draw(mask)
95
+ d.rectangle([x0, y0, x1, y1], fill=0)
96
+ if feather > 0:
97
+ mask = mask.filter(ImageFilter.GaussianBlur(radius=feather))
98
  return mask
99
 
100
+ def ImageDraw_ensure():
101
+ from PIL import ImageDraw
102
+ return ImageDraw
103
+
104
+ def feather_alpha_mask(size: Tuple[int,int], feather: int) -> Image.Image:
105
+ """Белый центр, мягкий край (для вставки оригинала поверх результата)"""
106
+ w, h = size
107
+ m = Image.new("L", (w, h), 255)
108
+ if feather <= 0:
109
+ return m
110
+ # делаем тёмную рамку и размазываем
111
+ d = ImageDraw_ensure().Draw(m)
112
+ d.rectangle([0,0,w-1,h-1], outline=0, width=feather*2)
113
+ m = m.filter(ImageFilter.GaussianBlur(radius=feather))
114
+ # нормализация центр белее
115
+ return m
116
+
117
+ def paste_center_preserving(base: Image.Image, original: Image.Image, box: Tuple[int,int,int,int], feather: int) -> Image.Image:
118
+ """Вставляет оригинал по box с мягким пером по краям."""
119
+ x0, y0, x1, y1 = box
120
+ orig_rgba = original.convert("RGBA")
121
+ alpha = feather_alpha_mask(original.size, feather)
122
+ orig_rgba.putalpha(alpha)
123
+ base = base.convert("RGBA")
124
+ base.alpha_composite(orig_rgba, dest=(x0, y0))
125
+ return base.convert("RGB")
126
+
127
+ def outpaint_once(
128
+ img: Image.Image,
129
+ L: int, R: int, T: int, B: int,
130
+ prompt: str, neg: str,
131
+ steps: int, cfg: float, seed: int,
132
+ feather: int, safe_max: int
133
+ ) -> Tuple[Image.Image, Image.Image, Tuple[int,int,int,int], int]:
134
+ """Генерит квадратное полотно, возвращает (outpaint, original, orig_box, used_seed)"""
135
+ img = _to_pil(img).convert("RGB")
136
+ # квадрат и позиция оригинала
137
+ W, H, orig_box = make_square_canvas(img, L, R, T, B)
138
+ # размер генерации с ограничением
139
+ gen_w, gen_h, _ = fit_to_safe_max(W, H, safe_max)
140
+ side = max(gen_w, gen_h)
141
+ # фон и маска
142
+ bg = build_bg_from_edges(img, side)
143
+ # пересчитываем orig_box под side (если side!=W/H, центруем)
144
+ cx, cy = side//2, side//2
145
+ x0 = cx - img.width//2
146
+ y0 = cy - img.height//2
147
+ orig_box_side = (x0, y0, x0 + img.width, y0 + img.height)
148
+ # кладём оригинал на фон (как исходник для inpaint)
149
+ cond = bg.copy()
150
+ cond.paste(img, (x0, y0))
151
+ mask = build_mask(side, orig_box_side, feather=max(1, feather//2))
152
+ # генерация
153
+ if seed is None or seed < 0:
154
+ seed = random.randint(0, 2**31-1)
155
+ g = torch.Generator(device=device)
156
+ if device == "cuda":
157
+ g = g.manual_seed(seed)
 
158
  else:
159
+ random.seed(seed)
160
+ out = pipe(
161
+ prompt=prompt if prompt.strip() else DEFAULT_PROMPT,
162
+ negative_prompt=(neg.strip() or DEFAULT_NEG),
163
+ image=cond,
164
+ mask_image=mask,
165
+ num_inference_steps=int(steps),
166
+ guidance_scale=float(cfg),
167
+ generator=g,
168
+ width=side,
169
+ height=side,
170
+ ).images[0]
171
+ return out, img, orig_box_side, seed
172
+
173
+ # -----------------------------
174
+ # Gradio core
175
+ # -----------------------------
176
+ def run(
177
+ img, prompt, neg, L, R, T, B,
178
+ steps, cfg, seed, feather, safe_max
179
+ ):
180
+ if img is None:
181
+ raise gr.Error("Загрузи изображение.")
182
+ out, orig, box, used_seed = outpaint_once(
183
+ img=img, L=L, R=R, T=T, B=B,
184
+ prompt=prompt or DEFAULT_PROMPT,
185
+ neg=neg or DEFAULT_NEG,
186
+ steps=steps, cfg=cfg, seed=seed,
187
+ feather=feather, safe_max=safe_max
188
+ )
189
+ # поверх возвращаем оригинал (чтобы его качество не менялось)
190
+ final = paste_center_preserving(out, orig, box, feather=max(8, feather))
191
+ # сохраняем полный PNG
192
+ side = final.size[0]
193
+ ts = int(time.time())
194
+ fname = f"/tmp/outpaint_{side}x{side}_{used_seed}.png"
195
+ final.save(fname, "PNG")
196
+ return final, fname, used_seed
 
 
197
 
198
  with gr.Blocks(css="""
199
+ #note {opacity:.8}
 
200
  """) as demo:
201
+ gr.Markdown("## Qwen Image Seamless Background Expand")
 
202
  with gr.Row():
203
+ with gr.Column():
204
+ in_img = gr.Image(label="Картинка", type="pil")
205
+ prompt = gr.Textbox(label="Prompt", value=DEFAULT_PROMPT, lines=2)
206
+ neg = gr.Textbox(label="Negative prompt", value=DEFAULT_NEG, lines=2)
207
+ with gr.Row():
208
+ L = gr.Number(label="Left (px)", value=256, precision=0)
209
+ R = gr.Number(label="Right (px)", value=256, precision=0)
210
+ with gr.Row():
211
+ T = gr.Number(label="Top (px)", value=256, precision=0)
212
+ B = gr.Number(label="Bottom (px)", value=256, precision=0)
213
+ with gr.Row():
214
+ steps = gr.Slider(10, 60, value=28, step=1, label="Steps")
215
+ cfg = gr.Slider(1.0, 12.0, value=5.5, step=0.1, label="Guidance scale")
216
  with gr.Row():
217
+ seed = gr.Number(label="Seed (-1=random)", value=-1, precision=0)
218
+ feather = gr.Slider(0, 64, value=24, step=1, label="Feather (px)")
219
+ safe_max = gr.Slider(1024, 2048, value=1792, step=64, label="Max side for generation (px)")
220
+ run_btn = gr.Button("Expand", variant="primary")
221
+ with gr.Column():
222
+ out_img = gr.Image(label="Результат", interactive=False)
223
+ file_out = gr.File(label="Скачать PNG (полный размер)")
224
+ used_seed = gr.Number(label="Used seed", interactive=False)
225
+
226
+ run_btn.click(
227
+ fn=run,
228
+ inputs=[in_img, prompt, neg, L, R, T, B, steps, cfg, seed, feather, safe_max],
229
+ outputs=[out_img, file_out, used_seed],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  )
231
 
232
  if __name__ == "__main__":
233
+ demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)