nimzuk commited on
Commit
2d15495
·
verified ·
1 Parent(s): 404d593

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +227 -217
app.py CHANGED
@@ -1,241 +1,251 @@
1
  # app.py
2
  import os
3
  import io
4
- import math
5
  import random
6
- from dataclasses import dataclass
7
- from typing import Tuple
8
-
9
- import gradio as gr
10
- import numpy as np
11
- from PIL import Image, ImageOps, ImageFilter
12
 
13
  import torch
14
- from diffusers import StableDiffusionInpaintPipeline
 
15
 
16
- # =========================
 
 
 
17
  # Config
18
- # =========================
19
- MODEL_ID = os.getenv("MODEL_ID", "runwayml/stable-diffusion-inpainting") # SD 1.5 Inpainting
20
- HF_TOKEN = os.getenv("HF_TOKEN", None)
 
 
21
 
22
- DEFAULT_PROMPT = "extend the image naturally"
23
  DEFAULT_NEG = (
24
- "frames, borders, phone mockup, collage tiles, UI elements, captions, watermark, "
25
- "logo, text, letters, words, signatures, artifacts, duplicate objects, extra limbs"
26
  )
27
 
28
- # Генерим при необходимости в даунскейле (для скорости/VRAM)
29
- SAFE_MAX = int(os.getenv("SAFE_MAX", "1024")) # макс. сторона для генерации
30
- FEATHER_PX = 24 # мягкое склеивание по границе оригинала в финальном композе
31
-
32
- # =========================
33
- # Helpers
34
- # =========================
35
- def to_pil(img) -> Image.Image:
36
- if isinstance(img, Image.Image):
37
- return img.convert("RGB")
38
- return Image.fromarray(img).convert("RGB")
39
-
40
- def make_square_with_border(im: Image.Image, border_px: int) -> Tuple[Image.Image, Image.Image]:
41
- """Делаем квадратный холст и маску (белым — где дорисовывать)."""
42
- w, h = im.size
43
- side = max(w, h)
44
- side = side + border_px * 2
45
- canvas = Image.new("RGB", (side, side), (128, 128, 128))
46
-
47
- # Центрируем оригинал
48
- ox = (side - w)//2
49
- oy = (side - h)//2
50
- canvas.paste(im, (ox, oy))
51
-
52
- # Маска: чёрный (0) — оригинал, белый (255) — где рисуем
53
- mask = Image.new("L", (side, side), color=255)
54
- mask.paste(0, (ox, oy, ox + w, oy + h))
55
- return canvas, mask
56
-
57
- def fit_to_safe_max(w: int, h: int, safe_max: int) -> Tuple[int, int, float]:
58
- """Укладываемся в SAFE_MAX, возвращаем целевые размеры и масштаб."""
59
- scale = 1.0
60
- if max(w, h) > safe_max:
61
- scale = safe_max / float(max(w, h))
62
- w = int(round(w * scale / 8.0)) * 8
63
- h = int(round(h * scale / 8.0)) * 8
64
- else:
65
- # SD любит кратность 8
66
- w = int(round(w / 8.0)) * 8
67
- h = int(round(h / 8.0)) * 8
68
- return w, h, scale
69
-
70
- def upscale_back(img: Image.Image, scale: float, target_wh: Tuple[int, int]) -> Image.Image:
71
- """Возвращаемся к исходному (большему) размеру после генерации в сниж. разрешении."""
72
- if scale == 1.0:
73
- return img
74
- tw, th = target_wh
75
- return img.resize((tw, th), Image.LANCZOS)
76
-
77
- def paste_center_preserving(original: Image.Image, outpaint: Image.Image, feather: int = FEATHER_PX) -> Image.Image:
78
- """Кладём исходник поверх результата (чтобы он остался чётким), мягко смешивая границы."""
79
- w, h = outpaint.size
80
- ow, oh = original.size
81
- ox = (w - ow)//2
82
- oy = (h - oh)//2
83
-
84
- # делаем маску с мягкими краями
85
- alpha = Image.new("L", (ow, oh), color=255)
86
- if feather > 0:
87
- # равномерное размывание по краю
88
- alpha = ImageOps.expand(alpha, border=feather, fill=0)
89
- alpha = alpha.filter(ImageFilter.GaussianBlur(radius=feather/2))
90
- alpha = alpha.crop((feather, feather, feather + ow, feather + oh))
91
-
92
- base = outpaint.convert("RGBA")
93
- top = original.convert("RGBA")
94
- top.putalpha(alpha)
95
- base.alpha_composite(top, (ox, oy))
96
- return base.convert("RGB")
97
-
98
- @dataclass
99
- class GenParams:
100
- prompt: str
101
- negative_prompt: str
102
- steps: int
103
- guidance: float
104
- seed: int
105
-
106
- # =========================
107
- # Pipeline
108
- # =========================
109
- device = "cuda" if torch.cuda.is_available() else "cpu"
110
- dtype = torch.float16 if (device == "cuda") else torch.float32
111
-
112
- pipe = StableDiffusionInpaintPipeline.from_pretrained(
113
  MODEL_ID,
114
- torch_dtype=dtype,
115
- use_auth_token=HF_TOKEN
 
116
  )
117
- pipe = pipe.to(device)
118
- pipe.safety_checker = None # без блюра
119
- pipe.enable_attention_slicing("auto")
120
 
121
- # =========================
122
- # Core generate
123
- # =========================
124
- def outpaint_generate(
 
 
 
 
 
 
 
 
 
 
125
  img: Image.Image,
126
  border_px: int,
127
- params: GenParams
128
- ):
129
- img = to_pil(img)
130
- # 1) расширяем до квадрата и строим маску
131
- canvas, mask = make_square_with_border(img, border_px)
132
-
133
- # 2) Поджимаем под SAFE_MAX для генерации
134
- W0, H0 = canvas.size
135
- gw, gh, scale = fit_to_safe_max(W0, H0, SAFE_MAX)
136
- if (gw, gh) != (W0, H0):
137
- canvas_small = canvas.resize((gw, gh), Image.LANCZOS)
138
- mask_small = mask.resize((gw, gh), Image.NEAREST)
139
- else:
140
- canvas_small, mask_small = canvas, mask
141
-
142
- # 3) Сид
143
- generator = None
144
- if params.seed is not None and params.seed != -1:
145
- generator = torch.Generator(device=device).manual_seed(int(params.seed))
146
-
147
- # 4) Генерация
148
- out = pipe(
149
- prompt=params.prompt or DEFAULT_PROMPT,
150
- negative_prompt=params.negative_prompt or DEFAULT_NEG,
151
- image=canvas_small,
152
- mask_image=mask_small,
153
- num_inference_steps=params.steps,
154
- guidance_scale=params.guidance,
155
- generator=generator,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  ).images[0]
157
 
158
- # 5) Возвращаемся к исходному квадрату
159
- out_full = upscale_back(out, scale, (W0, H0))
160
-
161
- # 6) Вставляем оригинал по центру с мягкой границей
162
- final = paste_center_preserving(original=img, outpaint=out_full, feather=FEATHER_PX)
163
-
164
- # 7) Сохраняем PNG без даунскейла
165
- side = final.size[0]
166
- save_name = f"outpaint_{side}x{side}_{random.randint(0, 2**31-1)}.png"
167
- save_path = os.path.join("/home/user/app", save_name)
168
- final.save(save_path, "PNG")
169
-
170
- return final, save_path
171
-
172
- # =========================
173
- # Preview (без модели)
174
- # =========================
175
- def preview_frame(img, border_px):
176
- if img is None:
177
- return None
178
- im = to_pil(img)
179
- w, h = im.size
180
- side = max(w, h) + border_px * 2
181
- canvas = Image.new("RGB", (side, side), (40, 40, 40))
182
- ox = (side - w)//2
183
- oy = (side - h)//2
184
- canvas.paste(im, (ox, oy))
185
- # рисуем тонкую рамку границы оригинала
186
- frame = Image.new("RGBA", (w, h), (255, 255, 255, 0))
187
- outline = ImageOps.expand(Image.new("L", (w-2, h-2), 0), border=1, fill=255)
188
- frame.putalpha(outline)
189
- canvas = canvas.convert("RGBA")
190
- canvas.alpha_composite(frame, (ox, oy))
191
- return canvas.convert("RGB")
192
-
193
- # =========================
194
  # Gradio UI
195
- # =========================
196
- with gr.Blocks(fill_height=True) as demo:
197
- gr.Markdown("# Qwen Image — Seamless Background Expand\nЗагрузи картинку, укажи, на сколько расширить (вокруг по периметру), и жми **Expand**.\nПо умолчанию промпт: *extend the image naturally*. Оригинал всегда остаётся по центру и сохраняет качество.")
 
 
198
 
199
  with gr.Row():
200
- with gr.Column(scale=1):
201
- inp_img = gr.Image(label="Картинка", type="pil")
202
- border = gr.Slider(0, 2000, value=256, step=8, label="Border (px) добавить по краю")
203
- with gr.Accordion("Промпты и параметры", open=False):
204
- prompt = gr.Textbox(label="Prompt", value=DEFAULT_PROMPT)
205
- neg = gr.Textbox(label="Negative prompt", value=DEFAULT_NEG)
206
- steps = gr.Slider(10, 60, value=30, step=1, label="Steps")
207
- guidance = gr.Slider(1.0, 12.0, value=5.5, step=0.1, label="Guidance scale")
208
- seed = gr.Number(value=-1, precision=0, label="Seed (-1 = random)")
209
- preview = gr.Image(label="Превью (рамка расширения)", interactive=False)
210
-
211
- # автопревью
212
- inp_img.change(preview_frame, [inp_img, border], preview, api_name=False)
213
- border.release(preview_frame, [inp_img, border], preview, api_name=False)
214
-
215
- with gr.Column(scale=1):
216
- out_img = gr.Image(label="Результат", interactive=False)
217
- file_out = gr.File(label="Скачать PNG", interactive=False)
218
-
219
- def go(image, border_px, prompt_txt, neg_txt, steps_v, guidance_v, seed_v):
220
- if image is None:
221
- raise gr.Error("Сначала загрузите изображение.")
222
- params = GenParams(
223
- prompt=prompt_txt or DEFAULT_PROMPT,
224
- negative_prompt=neg_txt or DEFAULT_NEG,
225
- steps=int(steps_v),
226
- guidance=float(guidance_v),
227
- seed=int(seed_v),
228
- )
229
- res, path = outpaint_generate(image, int(border_px), params)
230
- return res, path
231
-
232
- btn = gr.Button("Expand", variant="primary")
233
- btn.click(
234
- go,
235
- [inp_img, border, prompt, neg, steps, guidance, seed],
236
- [out_img, file_out]
237
- )
238
-
239
- # SSR включён в Spaces; локально можно так:
 
 
 
 
 
 
 
 
 
 
 
 
240
  if __name__ == "__main__":
241
  demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=True)
 
1
  # app.py
2
  import os
3
  import io
4
+ import time
5
  import random
6
+ from typing import Tuple, Optional
 
 
 
 
 
7
 
8
  import torch
9
+ from PIL import Image, ImageDraw, ImageFile, ImageFilter
10
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
11
 
12
+ import gradio as gr
13
+ from diffusers import StableDiffusionXLInpaintPipeline
14
+
15
+ # -------------------------
16
  # Config
17
+ # -------------------------
18
+ MODEL_ID = os.getenv("MODEL_ID", "stabilityai/stable-diffusion-xl-1.0-inpainting-0.1")
19
+ HF_TOKEN = os.getenv("HF_TOKEN", None) # добавь в Settings, если репо gated
20
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
21
+ DTYPE = torch.float16 if DEVICE == "cuda" else torch.float32
22
 
23
+ DEFAULT_PROMPT = "extend the image naturally, seamless realistic continuation, preserve original center"
24
  DEFAULT_NEG = (
25
+ "text, watermark, signature, logo, lowres, blurry, artifacts, deformed, distorted, "
26
+ "oversaturated, extra limbs, frames, borders, bad perspective"
27
  )
28
 
29
+ # -------------------------
30
+ # Pipeline (SDXL Inpaint)
31
+ # -------------------------
32
+ pipe = StableDiffusionXLInpaintPipeline.from_pretrained(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  MODEL_ID,
34
+ torch_dtype=DTYPE,
35
+ use_safetensors=True,
36
+ token=HF_TOKEN, # если None — ок; если задана — пройдёт с токеном
37
  )
38
+ pipe = pipe.to(DEVICE)
 
 
39
 
40
+ # Без xformers
41
+ # pipe.enable_xformers_memory_efficient_attention() # НЕ ВКЛЮЧАЕМ
42
+
43
+
44
+ # -------------------------
45
+ # Helpers
46
+ # -------------------------
47
+ def center_paste_coords(
48
+ base_w: int, base_h: int, obj_w: int, obj_h: int
49
+ ) -> Tuple[int, int]:
50
+ """Вычисляет координаты для центрирования."""
51
+ return ( (base_w - obj_w) // 2, (base_h - obj_h) // 2 )
52
+
53
+ def make_square_with_border(
54
  img: Image.Image,
55
  border_px: int,
56
+ bg=(128, 128, 128)
57
+ ) -> Tuple[Image.Image, Image.Image, Tuple[int, int, int, int]]:
58
+ """
59
+ Создаёт квадратный холст (max(w,h)+2*border), кладёт исходник по центру.
60
+ Возвращает:
61
+ padded_image (RGB), mask (L, 0 центр/255 поля), bbox исходника (x,y,w,h).
62
+ """
63
+ img = img.convert("RGB")
64
+ w, h = img.size
65
+ side = max(w, h) + 2 * border_px
66
+ canvas = Image.new("RGB", (side, side), bg)
67
+
68
+ x, y = center_paste_coords(side, side, w, h)
69
+ canvas.paste(img, (x, y))
70
+
71
+ # Маска: чёрный (0) = защитить (центр), белый (255) = дорисовать (поля)
72
+ mask = Image.new("L", (side, side), 255)
73
+ draw = ImageDraw.Draw(mask)
74
+ draw.rectangle([x, y, x + w - 1, y + h - 1], fill=0)
75
+
76
+ return canvas, mask, (x, y, w, h)
77
+
78
+ def feather_mask(mask: Image.Image, feather_px: int) -> Image.Image:
79
+ """Смягчаем границу маски GaussianBlur-ом для более естественного склеивания."""
80
+ if feather_px <= 0:
81
+ return mask
82
+ return mask.filter(ImageFilter.GaussianBlur(radius=feather_px))
83
+
84
+ def overlay_original_center(
85
+ out: Image.Image, orig: Image.Image, bbox: Tuple[int, int, int, int], feather_px: int
86
+ ) -> Image.Image:
87
+ """
88
+ Поверх результата накладываем оригинал (чтобы центр гарантированно остался без изменений).
89
+ Делаем мягкую маску по краям вставки, чтобы не было резкого стыка.
90
+ """
91
+ out = out.convert("RGB")
92
+ img = orig.convert("RGB")
93
+ x, y, w, h = bbox
94
+
95
+ # Маска для вставки: белый = виден оригинал, чёрный = виден out; мягкий край
96
+ m = Image.new("L", (out.width, out.height), 0)
97
+ draw = ImageDraw.Draw(m)
98
+ draw.rectangle([x, y, x + w - 1, y + h - 1], fill=255)
99
+ if feather_px > 0:
100
+ m = m.filter(ImageFilter.GaussianBlur(radius=feather_px))
101
+
102
+ out.paste(img, (x, y), m.crop((x, y, x + w, y + h)))
103
+ return out
104
+
105
+ def format_dims(w: int, h: int) -> str:
106
+ return f"{w}×{h}px"
107
+
108
+ # -------------------------
109
+ # Core outpaint
110
+ # -------------------------
111
+ @torch.inference_mode()
112
+ def outpaint_generate(
113
+ image: Image.Image,
114
+ border_px: int,
115
+ prompt: str,
116
+ neg_prompt: str,
117
+ steps: int,
118
+ guidance: float,
119
+ seed: Optional[int],
120
+ feather_px: int,
121
+ ) -> Tuple[Image.Image, str]:
122
+ """
123
+ Делает аутпейнтинг с SDXL Inpaint:
124
+ - готовит квадратный канвас и маску (белые поля = дорисовка),
125
+ - запускает пайплайн,
126
+ - мягко вставляет исходник по центру поверх результата,
127
+ - сохраняет PNG полноразмерный.
128
+ """
129
+ if not prompt or not prompt.strip():
130
+ prompt = DEFAULT_PROMPT
131
+ if not neg_prompt or not neg_prompt.strip():
132
+ neg_prompt = DEFAULT_NEG
133
+
134
+ # 1) Квадрат + маска
135
+ padded, mask, bbox = make_square_with_border(image, border_px, bg=(128, 128, 128))
136
+ mask = feather_mask(mask, max(0, feather_px))
137
+
138
+ # 2) Генератор по seed
139
+ if seed in (None, -1, ""):
140
+ seed = random.randint(0, 2**31 - 1)
141
+ g = torch.Generator(device=DEVICE)
142
+ g.manual_seed(int(seed))
143
+
144
+ # 3) SDXL Inpaint
145
+ # В SDXL-inpaint ничего дополнительно передавать не нужно (никаких added_cond_kwargs).
146
+ result = pipe(
147
+ prompt=prompt,
148
+ negative_prompt=neg_prompt,
149
+ image=padded, # PIL RGB
150
+ mask_image=mask, # PIL L (255 = дорисовать; 0 = сохраняем)
151
+ num_inference_steps=int(steps),
152
+ guidance_scale=float(guidance),
153
+ generator=g,
154
  ).images[0]
155
 
156
+ # 4) Поверх снова центр, чтоб наверняка был 1:1
157
+ final = overlay_original_center(result, image, bbox, feather_px=max(0, feather_px // 2))
158
+
159
+ # 5) Сохранение PNG в полном размере
160
+ outdir = "outputs"
161
+ os.makedirs(outdir, exist_ok=True)
162
+ side = final.width # квадрат
163
+ fname = f"outpaint_{side}x{side}_{seed}.png"
164
+ path = os.path.join(outdir, fname)
165
+ final.save(path, "PNG")
166
+
167
+ return final, path
168
+
169
+ # -------------------------
170
+ # Preview: рисуем чёрную рамку в том месте, где будет дорисовка
171
+ # -------------------------
172
+ def make_preview(image: Image.Image, border_px: int) -> Tuple[Image.Image, str, str]:
173
+ if image is None:
174
+ return None, "", ""
175
+
176
+ w, h = image.size
177
+ side = max(w, h) + 2 * int(border_px)
178
+ padded = Image.new("RGB", (side, side), (240, 240, 240))
179
+ x, y = center_paste_coords(side, side, w, h)
180
+ padded.paste(image.convert("RGB"), (x, y))
181
+
182
+ # Рамка места дорисовки
183
+ draw = ImageDraw.Draw(padded)
184
+ # Внутренняя окантовка исходника (покажем контур)
185
+ draw.rectangle([x, y, x + w - 1, y + h - 1], outline=(0, 0, 0), width=2)
186
+
187
+ return padded, f"Original: {format_dims(w, h)}", f"Result: {format_dims(side, side)}"
188
+
189
+ # -------------------------
 
 
190
  # Gradio UI
191
+ # -------------------------
192
+ with gr.Blocks(css="""
193
+ #dims {font-size: 14px; opacity: 0.8}
194
+ """) as demo:
195
+ gr.Markdown("## Qwen Outpaint (SDXL Inpaint)\nЗагрузите изображение, выберите рамку (px) — модель дорисует края естественно. Центр сохраняется без изменений.")
196
 
197
  with gr.Row():
198
+ with gr.Column():
199
+ image_in = gr.Image(label="Input image", type="pil")
200
+ border = gr.Slider(0, 2048, value=256, step=8, label="Padding (px) around (square)")
201
+ feather = gr.Slider(0, 64, value=16, step=1, label="Feather border (px)")
202
+ prompt = gr.Textbox(label="Prompt", value=DEFAULT_PROMPT)
203
+ neg_prompt = gr.Textbox(label="Negative prompt", value=DEFAULT_NEG)
204
+ steps = gr.Slider(10, 60, value=30, step=1, label="Steps")
205
+ guidance = gr.Slider(1.0, 12.0, value=5.5, step=0.1, label="CFG (guidance)")
206
+ seed = gr.Number(value=-1, precision=0, label="Seed (-1 random)")
207
+ run_btn = gr.Button("Generate", variant="primary")
208
+
209
+ gr.Markdown('<div id="dims"></div>')
210
+ dims_orig = gr.Markdown()
211
+ dims_final = gr.Markdown()
212
+
213
+ with gr.Column():
214
+ preview = gr.Image(label="Preview (where outpaint will happen)", interactive=False)
215
+ result = gr.Image(label="Result", interactive=False)
216
+ file_out = gr.File(label="Download PNG")
217
+
218
+ # Preview update
219
+ def _update_preview(img, b):
220
+ if img is None:
221
+ return None, "", ""
222
+ p, d1, d2 = make_preview(img, int(b))
223
+ return p, f"<span id='dims'>{d1}</span>", f"<span id='dims'>{d2}</span>"
224
+
225
+ image_in.change(_update_preview, [image_in, border], [preview, dims_orig, dims_final])
226
+ border.release(_update_preview, [image_in, border], [preview, dims_orig, dims_final])
227
+
228
+ # Generate
229
+ def go(img, b, fthr, prm, nprm, stp, cfg, sd):
230
+ if img is None:
231
+ raise gr.Error("Загрузите изображение.")
232
+ final, path = outpaint_generate(
233
+ image=img,
234
+ border_px=int(b),
235
+ prompt=(prm or DEFAULT_PROMPT),
236
+ neg_prompt=(nprm or DEFAULT_NEG),
237
+ steps=int(stp),
238
+ guidance=float(cfg),
239
+ seed=int(sd) if sd is not None else -1,
240
+ feather_px=int(fthr),
241
+ )
242
+ return final, path
243
+
244
+ run_btn.click(
245
+ go,
246
+ [image_in, border, feather, prompt, neg_prompt, steps, guidance, seed],
247
+ [result, file_out]
248
+ )
249
+
250
  if __name__ == "__main__":
251
  demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=True)