Madiy commited on
Commit
cb8c473
·
verified ·
1 Parent(s): f3edaa2

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +201 -258
  2. requirements.txt +4 -1
app.py CHANGED
@@ -11,102 +11,113 @@ from diffusers import StableDiffusionInpaintPipeline
11
  from transformers import BlipProcessor, BlipForConditionalGeneration
12
  from modelscope.pipelines import pipeline
13
  from modelscope.utils.constant import Tasks
 
14
  import torchvision.transforms.functional as F
15
  sys.modules["torchvision.transforms.functional_tensor"] = F
16
 
17
  from basicsr.archs.rrdbnet_arch import RRDBNet
18
  from realesrgan import RealESRGANer
19
 
20
- # LOAD MODELS AT STARTUP
21
- # ── Real-ESRGAN ──────────────────────────────────────────────
22
- print("Downloading Real-ESRGAN weights...")
23
 
24
- weights_dir = "weights"
 
 
 
 
 
25
  os.makedirs(weights_dir, exist_ok=True)
26
  weights_path = f"{weights_dir}/RealESRGAN_x4plus.pth"
 
27
  if not os.path.exists(weights_path):
28
  url = "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth"
29
  urllib.request.urlretrieve(url, weights_path)
30
  print("Weights downloaded.")
31
 
32
  esrgan_model = RRDBNet(
33
- num_in_ch=3,
34
- num_out_ch=3,
35
- num_feat=64,
36
- num_block=23,
37
- num_grow_ch=32,
38
- scale=4
39
  )
40
 
41
  enhancer = RealESRGANer(
42
- scale=4,
43
- model_path=weights_path,
44
- model=esrgan_model,
45
- tile=512,
46
- tile_pad=10,
47
- pre_pad=0,
48
- half=True,
49
  )
50
  print("Real-ESRGAN ready.")
51
 
52
- # ── SD Inpainting ────────────────────────────────────────────
53
- print("Loading SD Inpainting...")
 
 
 
54
 
55
  inpaint = StableDiffusionInpaintPipeline.from_pretrained(
56
  "stabilityai/stable-diffusion-2-inpainting",
57
  torch_dtype=torch.float16,
58
- variant= "fp16"
59
  )
60
- print("SD Inpainting ready.")
 
 
 
 
 
 
 
61
 
62
- # COLORING
63
  colorizer = pipeline(
64
  Tasks.image_colorization,
65
  model="damo/cv_ddcolor_image-colorization"
66
- # DDColor = dedicated colorization model
67
- # Trained specifically to add color without changing structure
68
  )
 
69
 
70
- print("Colorizer ready.")
71
 
72
-
73
- # BLIP
 
74
  print("Loading BLIP...")
75
 
76
- blip_processor = BlipProcessor.from_pretrained(
77
- "Salesforce/blip-image-captioning-base"
78
- )
79
  blip_model = BlipForConditionalGeneration.from_pretrained(
80
  "Salesforce/blip-image-captioning-base",
81
  torch_dtype=torch.float16,
82
  )
83
- # Same — no .to("cuda") at load time for ZeroGPU
84
  print("All models loaded.")
85
 
86
 
 
87
  # HELPER FUNCTIONS
 
 
88
  def is_greyscale(image):
89
- rgb = image.convert("RGB")
 
90
  r, g, b = rgb.split()
91
- r_arr = np.array(r, dtype=float)
92
- g_arr = np.array(g, dtype=float)
93
- b_arr = np.array(b, dtype=float)
94
  diff_rg = np.mean(np.abs(r_arr - g_arr))
95
  diff_rb = np.mean(np.abs(r_arr - b_arr))
96
- return (diff_rg < 10 and diff_rb < 10)
 
97
 
98
  def get_caption(image):
 
 
 
99
  blip_model.to("cuda")
100
- inputs = blip_processor(
101
- image.convert("RGB"),
102
- return_tensors="pt"
103
  ).to("cuda", torch.float16)
104
- # Inside @spaces.GPU function, cuda is available
105
- output = blip_model.generate(**inputs, max_new_tokens=60)
106
  caption = blip_processor.decode(output[0], skip_special_tokens=True)
107
  return caption
108
 
109
- def build_prompt(caption, image): #for outpaint
 
 
 
 
 
110
  bw = is_greyscale(image)
111
  style_hint = (
112
  "black and white photography, monochrome, greyscale, "
@@ -117,135 +128,123 @@ def build_prompt(caption, image): #for outpaint
117
  return (
118
  f"seamless natural continuation of scene, {caption}, "
119
  f"{style_hint}, extending background only, "
120
- f"same atmosphere, high quality"
121
  )
122
 
123
 
 
 
 
124
 
125
  @spaces.GPU
126
  def enhance_image(image, scale_factor):
127
  if image is None:
128
  raise gr.Error("Please upload an image first.")
129
 
130
- # Move models to GPU — happens inside the decorated function
131
- # because GPU is only available here
132
  enhancer.device = torch.device("cuda")
133
  enhancer.half = True
134
 
135
  image_array = np.array(image)
136
  image_array = image_array[:, :, :3]
137
- outscale = 4 if scale_factor == "4x" else 2
 
 
138
 
139
  try:
140
  output_array, _ = enhancer.enhance(image_array, outscale=outscale)
141
  except RuntimeError as e:
142
  raise gr.Error(f"Enhancement failed: {e}. Try a smaller image.")
143
 
144
- output_rgb = output_array[:, :, ::-1]
145
- output_image = Image.fromarray(output_rgb)
 
 
 
 
 
 
146
 
147
- original_size = f"{image.width}×{image.height}"
148
- new_size = f"{output_image.width}×{output_image.height}"
149
 
150
- return output_image, f"Original: {original_size} → Enhanced: {new_size}"
 
 
151
 
152
  @spaces.GPU
153
  def colour_image(image, strength):
154
-
155
- if Image is None:
156
- raise gr.Error("Please Upload the Image")
157
-
158
- # Convert PIL to numpy RGB
159
- img_array = image.convert("RGB")
160
- # Resize so longest side = 512
161
- # DDColor works best at this resolution
162
  target = 512
163
  ratio = min(target / image.width, target / image.height)
164
- w = int(image.width * ratio)
165
- h = int(image.height * ratio)
166
- image = image.resize((w, h), Image.LANCZOS)
167
- # LANCZOS = high quality resampling, preserves sharp edges
168
- # DDColor expects BGR (OpenCV format)
169
- # Convert PIL numpy BGR for DDColor
170
  img_rgb = np.array(image)
171
- # np.array(PIL) = numpy array shape (h, w, 3) in RGB
172
  img_bgr = img_rgb[:, :, ::-1]
 
173
 
174
- #run colorizer
175
- result= colorizer(img_bgr)
176
- output_bgr= result["output_img"]
177
 
178
- # Convert BGR → RGB for PIL
179
  output_rgb = output_bgr[:, :, ::-1]
 
180
 
181
- # Apply strength blending
182
  if strength < 1.0:
183
- grey = np.array(image.convert("L"))
184
- # convert("L") = convert to greyscale (single channel)
185
  grey_3ch = np.stack([grey, grey, grey], axis=-1)
186
- # stack same channel 3 times (h, w, 3) array
187
- # needed to match output_rgb shape for blending
188
  output_rgb = (
189
- strength * output_rgb.astype(float) +
190
  (1 - strength) * grey_3ch.astype(float)
191
  ).astype(np.uint8)
192
- # Linear blend: weighted average of colored and grey
193
- # .astype(np.uint8) = convert back to 0-255 integers
194
- return Image.fromarray(output_rgb)
195
 
 
 
 
 
196
 
197
- def get_caption(image):
198
- inputs = blip_processor(image.convert("RGB"), return_tensors="pt").to("cuda", torch.float16)
199
- output = blip_model.generate(**inputs, max_new_tokens=60)
200
- caption = blip_processor.decode(output[0], skip_special_tokens=True)
201
- return caption
202
-
203
-
204
- def build_prompt(caption):
205
- return (
206
- f"seamless continuation of a scene with {caption}, "
207
- f"same exact lighting, same exact style, "
208
- f"same background, extending the existing scene naturally, "
209
- f"no new objects, no new subjects, only environment continuation"
210
- )
211
 
 
 
 
212
 
213
  def extend_one_side(image, direction, pixels, prompt, negative_prompt):
214
- # CORE LOGIC — extends one side by a SMALL number of pixels
215
- # Called multiple times in small steps instead of one big jump
216
- # Small steps = SD always has lots of context = coherent output
217
-
218
- from PIL import ImageDraw
219
-
220
- orig_w = image.width
221
- orig_h = image.height
222
 
223
- new_w = orig_w
224
- new_h = orig_h
 
 
225
  paste_x = 0
226
  paste_y = 0
227
 
228
  if direction == "left":
229
  new_w = orig_w + pixels
230
  paste_x = pixels
231
-
232
  elif direction == "right":
233
  new_w = orig_w + pixels
234
- paste_x = 0
235
-
236
  elif direction == "top":
237
  new_h = orig_h + pixels
238
  paste_y = pixels
239
-
240
  elif direction == "bottom":
241
  new_h = orig_h + pixels
242
- paste_y = 0
243
 
244
- # Round to multiple of 8 — SD requirement
245
  new_w = (new_w // 8) * 8
246
  new_h = (new_h // 8) * 8
247
 
248
- # Recalculate after rounding
249
  if direction in ["left", "right"]:
250
  pixels = new_w - orig_w
251
  else:
@@ -256,259 +255,204 @@ def extend_one_side(image, direction, pixels, prompt, negative_prompt):
256
  if direction == "top":
257
  paste_y = pixels
258
 
259
- # Build black canvas and paste original into it
260
  canvas = Image.new("RGB", (new_w, new_h), (0, 0, 0))
261
  canvas.paste(image, (paste_x, paste_y))
262
 
263
- # ── Build mask ────────────────────────────────────────────
264
  mask = Image.new("L", (new_w, new_h), 255)
265
- # All white = generate everywhere
266
  draw = ImageDraw.Draw(mask)
 
 
267
 
268
  feather = 30
269
- # This ensures the black region doesn't touch the very edge of original
270
- # leaving a thin white strip that GaussianBlur will turn into a gradient
271
-
272
 
273
  if direction == "left":
274
- draw.rectangle([
275
- paste_x + feather, feather,
276
- new_w - feather, new_h - feather
277
- ], fill=0)
278
-
279
  elif direction == "right":
280
- draw.rectangle([
281
- feather, feather,
282
- orig_w - feather, new_h - feather
283
- ], fill=0)
284
-
285
  elif direction == "top":
286
- draw.rectangle([
287
- feather, paste_y + feather,
288
- new_w - feather, new_h - feather
289
- ], fill=0)
290
-
291
  elif direction == "bottom":
292
- draw.rectangle([
293
- feather, feather,
294
- new_w - feather, orig_h - feather
295
- ], fill=0)
296
 
297
-
298
- #Apply GaussianBlur to the mask AFTER drawing
299
- # This is what creates REAL feathering — a soft gradient at the boundary
300
- mask= mask.filter(ImageFilter.GaussianBlur(radius=30))
301
 
302
-
303
  sd_size = 768
304
  canvas_sd = canvas.resize((sd_size, sd_size), Image.LANCZOS)
305
  mask_sd = mask.resize((sd_size, sd_size), Image.LANCZOS)
306
- # LANCZOS = high quality downsampling, preserves sharp details
307
 
308
  result = inpaint(
309
- prompt = prompt,
310
- image = canvas_sd,
311
- mask_image = mask_sd,
312
- height = sd_size,
313
- width = sd_size,
314
- num_inference_steps = 40,
315
- guidance_scale = 7.0,
316
- negative_prompt = negative_prompt,
317
  )
318
 
319
- generated_512 = result.images[0]
320
-
321
- generated_full = generated_512.resize((new_w, new_h), Image.LANCZOS)
322
  return generated_full
323
 
 
 
 
 
 
324
  @spaces.GPU
325
  def outpaint_image(image, direction, extend_percent, custom_prompt, progress=gr.Progress()):
326
 
327
  if image is None:
328
  raise gr.Error("Please upload an image first.")
 
329
  inpaint.to("cuda")
330
  blip_model.to("cuda")
 
331
 
332
- # Resize input to max 512 on longest side
333
  max_side = 512
334
  ratio = min(max_side / image.width, max_side / image.height)
335
-
336
- new_size = (int(image.width * ratio), int(image.height * ratio))
337
- image = image.resize(new_size, Image.LANCZOS)
 
338
 
339
  progress(0.05, desc="Analyzing image with BLIP...")
340
 
341
- if custom_prompt.strip():
342
- blip_caption = custom_prompt.strip()
343
- else:
344
- blip_caption = get_caption(image)
345
 
346
- prompt = build_prompt(blip_caption)
 
 
 
347
 
348
- negative_prompt = (
349
  "blurry, bad quality, watermark, text, "
350
  "new person, new face, new subject, extra people, "
351
- "colorful, vibrant colors, color photography, "
352
  "duplicate, tiled, repeated pattern, border, frame, "
353
  "seam, visible edge, abrupt change, inconsistent, "
354
  "distorted, unnatural, different style, different era"
355
  )
 
 
 
 
 
356
 
357
- # ── KEY CHANGE: small fixed step size, multiple passes ────
358
  STEP_PX = 64
359
- # Add only 64 pixels per pass instead of the full extension at once
360
- # 64px out of a 512px image = 12.5% of canvas = SD has 87.5% context
361
- # Compare to before: 35% extension = SD only had 65% context
362
- # More context = SD understands the scene = coherent generation
363
-
364
- # Calculate total pixels needed per direction
365
- extend = extend_percent / 100.0
366
- h_total = int(image.width * extend)
367
- # Total horizontal pixels to add on each side
368
- v_total = int(image.height * extend)
369
- # Total vertical pixels to add on each side
370
 
371
  def make_passes(side, total_px):
372
- # Break total_px into multiple STEP_PX passes
373
- # Example: total=180px, STEP_PX=64 → passes of [64, 64, 52]
374
- passes = []
375
- remaining = total_px
376
  while remaining > 0:
377
  step = min(STEP_PX, remaining)
378
- # min() = don't overshoot — last step may be smaller
379
  passes.append((side, step))
380
  remaining -= step
381
  return passes
382
- # Returns list of (direction, pixels) tuples
383
- # Each tuple = one call to extend_one_side
384
 
385
- # Build full pass list based on chosen direction
386
  if direction == "Horizontal":
387
  passes = make_passes("right", h_total) + make_passes("left", h_total)
388
- # Extend right in small steps, then left in small steps
389
-
390
  elif direction == "Vertical":
391
  passes = make_passes("bottom", v_total) + make_passes("top", v_total)
392
- # Bottom first (more grounded content), then top
393
-
394
  else:
395
- # Both — all four sides in small steps
396
  passes = (
397
- make_passes("bottom", v_total) +
398
- make_passes("top", v_total) +
399
- make_passes("right", h_total) +
400
- make_passes("left", h_total)
401
  )
402
 
403
  total_passes = len(passes)
404
  current_image = image
405
 
406
  for i, (side, px) in enumerate(passes):
407
- progress_val = 0.1 + 0.85 * (i / total_passes)
408
- progress(progress_val, desc=f"Pass {i+1}/{total_passes} extending {side} by {px}px")
409
-
410
- current_image = extend_one_side(
411
- current_image,
412
- side,
413
- px,
414
- prompt,
415
- negative_prompt
416
  )
417
- # Each pass returns a slightly larger image
418
- # Next pass uses that as input — builds on previous result
419
 
420
  progress(1.0, desc="Done!")
421
 
422
- return (
423
- current_image,
424
- f"BLIP caption:\n{blip_caption}\n\nFull prompt:\n{prompt}"
425
- )
426
 
 
 
427
  # GRADIO UI
428
- with gr.Blocks(title="CanvasAI — Enhance & Outpaint") as demo:
 
 
429
 
430
- gr.Markdown("# CanvasAI")
431
  gr.Markdown(
432
- "**Enhance** any image with Real-ESRGAN super resolution, "
433
- "or **Outpaint** to extend the scene in any direction using AI."
 
434
  )
435
 
436
  with gr.Tabs():
437
 
438
- with gr.Tab(" Enhance"):
439
  gr.Markdown("Upscale and sharpen any image 2x or 4x using Real-ESRGAN.")
440
  with gr.Row():
441
  with gr.Column():
442
  enh_input = gr.Image(label="Upload Image", type="pil")
443
  enh_scale = gr.Dropdown(
444
- choices=["2x", "4x"],
445
- value="4x",
446
- label="Upscale Factor"
447
  )
448
  enh_btn = gr.Button("Enhance", variant="primary")
449
  with gr.Column():
450
  enh_output = gr.Image(label="Result", type="pil", interactive=False)
451
  enh_info = gr.Textbox(label="Size Info", interactive=False)
452
 
453
- enh_btn.click(
454
- fn=enhance_image,
455
- inputs=[enh_input, enh_scale],
456
- outputs=[enh_output, enh_info]
457
- )
458
 
459
- with gr.Tab("Colourize"):
460
- gr.Markdown("Upload a black and white image. "
461
- "AI adds natural, realistic colours while preserving the original structure.")
 
 
462
  with gr.Row():
463
  with gr.Column():
464
- col_input= gr.Image(
465
- label="Upload Image",
466
- type="pil",
467
- )
468
- col_strength= gr.Slider(
469
- minimum= 0.3,
470
- maximum= 0.7,
471
- value= 0.5,
472
- step= 0.05,
473
- label= "Colorization Strength",
474
- info= "Low = subtle tint, stays close to original. High = vivid colours."
475
- )
476
-
477
- col_btn= gr.Button(
478
- "Colorize", variant="primary"
479
  )
 
480
  with gr.Column():
481
- col_output= gr.Image(label="Colorized Result", type="pil", interactive=False)
482
- col_btn.click(
483
- fn= colour_image,
484
- inputs= [col_input, col_strength],
485
- outputs= [col_output]
486
- )
487
 
488
- with gr.Tab(" Outpaint"):
489
  gr.Markdown(
490
  "Upload an image and extend it in any direction. "
491
  "BLIP reads the scene automatically — no prompt needed."
492
  )
493
  with gr.Row():
494
  with gr.Column():
495
- out_input = gr.Image(label="Upload Image", type="pil")
496
- out_dir = gr.Radio(
497
- choices=["Horizontal", "Vertical", "Both"],
498
- value="Horizontal",
499
- label="Direction"
500
- )
501
- out_pct = gr.Slider(
502
- minimum=10, maximum=50,
503
- value=25, step=5,
504
- label="Extend by (%)"
505
  )
 
506
  out_prompt = gr.Textbox(
507
  label="Custom Prompt (optional)",
508
- placeholder="Leave empty for auto-detection",
509
  lines=2
510
  )
511
- out_btn = gr.Button("🔲 Outpaint", variant="primary")
512
  with gr.Column():
513
  out_output = gr.Image(label="Result", type="pil", interactive=False)
514
  out_caption = gr.Textbox(label="Prompt Used", interactive=False, lines=4)
@@ -519,5 +463,4 @@ with gr.Blocks(title="CanvasAI — Enhance & Outpaint") as demo:
519
  outputs=[out_output, out_caption]
520
  )
521
 
522
- #launch
523
  demo.launch()
 
11
  from transformers import BlipProcessor, BlipForConditionalGeneration
12
  from modelscope.pipelines import pipeline
13
  from modelscope.utils.constant import Tasks
14
+
15
  import torchvision.transforms.functional as F
16
  sys.modules["torchvision.transforms.functional_tensor"] = F
17
 
18
  from basicsr.archs.rrdbnet_arch import RRDBNet
19
  from realesrgan import RealESRGANer
20
 
 
 
 
21
 
22
+ # ================================================================
23
+ # LOAD REAL-ESRGAN
24
+ # ================================================================
25
+ print("Setting up Real-ESRGAN...")
26
+
27
+ weights_dir = "weights"
28
  os.makedirs(weights_dir, exist_ok=True)
29
  weights_path = f"{weights_dir}/RealESRGAN_x4plus.pth"
30
+
31
  if not os.path.exists(weights_path):
32
  url = "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth"
33
  urllib.request.urlretrieve(url, weights_path)
34
  print("Weights downloaded.")
35
 
36
  esrgan_model = RRDBNet(
37
+ num_in_ch=3, num_out_ch=3, num_feat=64,
38
+ num_block=23, num_grow_ch=32, scale=4
 
 
 
 
39
  )
40
 
41
  enhancer = RealESRGANer(
42
+ scale=4, model_path=weights_path, model=esrgan_model,
43
+ tile=512, tile_pad=10, pre_pad=0, half=True,
 
 
 
 
 
44
  )
45
  print("Real-ESRGAN ready.")
46
 
47
+
48
+ # ================================================================
49
+ # LOAD SD2 INPAINTING
50
+ # ================================================================
51
+ print("Loading SD2 Inpainting...")
52
 
53
  inpaint = StableDiffusionInpaintPipeline.from_pretrained(
54
  "stabilityai/stable-diffusion-2-inpainting",
55
  torch_dtype=torch.float16,
56
+ variant="fp16",
57
  )
58
+ # No .to("cuda") — ZeroGPU rule: only move inside @spaces.GPU functions
59
+ print("SD2 Inpainting ready.")
60
+
61
+
62
+ # ================================================================
63
+ # LOAD DDCOLOR
64
+ # ================================================================
65
+ print("Loading DDColor...")
66
 
 
67
  colorizer = pipeline(
68
  Tasks.image_colorization,
69
  model="damo/cv_ddcolor_image-colorization"
 
 
70
  )
71
+ print("DDColor ready.")
72
 
 
73
 
74
+ # ================================================================
75
+ # LOAD BLIP
76
+ # ================================================================
77
  print("Loading BLIP...")
78
 
79
+ blip_processor = BlipProcessor.from_pretrained("Salesforce/blip-image-captioning-base")
 
 
80
  blip_model = BlipForConditionalGeneration.from_pretrained(
81
  "Salesforce/blip-image-captioning-base",
82
  torch_dtype=torch.float16,
83
  )
 
84
  print("All models loaded.")
85
 
86
 
87
+ # ================================================================
88
  # HELPER FUNCTIONS
89
+ # ================================================================
90
+
91
  def is_greyscale(image):
92
+ # Returns True if image is effectively black and white
93
+ rgb = image.convert("RGB")
94
  r, g, b = rgb.split()
95
+ r_arr = np.array(r, dtype=float)
96
+ g_arr = np.array(g, dtype=float)
97
+ b_arr = np.array(b, dtype=float)
98
  diff_rg = np.mean(np.abs(r_arr - g_arr))
99
  diff_rb = np.mean(np.abs(r_arr - b_arr))
100
+ return diff_rg < 10 and diff_rb < 10
101
+
102
 
103
  def get_caption(image):
104
+ # BUG 3 FIX: single definition with blip_model.to("cuda")
105
+ # Previous code defined get_caption twice — Python used the last
106
+ # definition which was missing .to("cuda") causing cuda errors
107
  blip_model.to("cuda")
108
+ inputs = blip_processor(
109
+ image.convert("RGB"), return_tensors="pt"
 
110
  ).to("cuda", torch.float16)
111
+ output = blip_model.generate(**inputs, max_new_tokens=60)
 
112
  caption = blip_processor.decode(output[0], skip_special_tokens=True)
113
  return caption
114
 
115
+
116
+ def build_outpaint_prompt(caption, image):
117
+ # BUG 4+5 FIX: single function, renamed from build_prompt
118
+ # Previous code defined build_prompt twice with different signatures
119
+ # Python used the last (one-arg) version, silently losing B&W detection
120
+ # Renamed to build_outpaint_prompt — no ambiguity possible
121
  bw = is_greyscale(image)
122
  style_hint = (
123
  "black and white photography, monochrome, greyscale, "
 
128
  return (
129
  f"seamless natural continuation of scene, {caption}, "
130
  f"{style_hint}, extending background only, "
131
+ f"same atmosphere, high quality, no new subjects"
132
  )
133
 
134
 
135
+ # ================================================================
136
+ # ENHANCEMENT
137
+ # ================================================================
138
 
139
  @spaces.GPU
140
  def enhance_image(image, scale_factor):
141
  if image is None:
142
  raise gr.Error("Please upload an image first.")
143
 
 
 
144
  enhancer.device = torch.device("cuda")
145
  enhancer.half = True
146
 
147
  image_array = np.array(image)
148
  image_array = image_array[:, :, :3]
149
+ # Keep only RGB drop alpha channel if RGBA (transparent PNG)
150
+
151
+ outscale = 4 if scale_factor == "4x" else 2
152
 
153
  try:
154
  output_array, _ = enhancer.enhance(image_array, outscale=outscale)
155
  except RuntimeError as e:
156
  raise gr.Error(f"Enhancement failed: {e}. Try a smaller image.")
157
 
158
+ output_image = Image.fromarray(output_array)
159
+ # Real-ESRGAN returns RGB — no channel flip needed
160
+
161
+ return (
162
+ output_image,
163
+ f"Original: {image.width}x{image.height} -> "
164
+ f"Enhanced: {output_image.width}x{output_image.height}"
165
+ )
166
 
 
 
167
 
168
+ # ================================================================
169
+ # COLORIZATION
170
+ # ================================================================
171
 
172
  @spaces.GPU
173
  def colour_image(image, strength):
174
+ # BUG 1 FIX: was "if Image is None" (capital I = PIL class, never None)
175
+ if image is None:
176
+ raise gr.Error("Please upload an image first.")
177
+
178
+ image = image.convert("RGB")
179
+ # BUG 3 FIX: removed dead variable img_array that was assigned
180
+ # before resize and never used
181
+
182
  target = 512
183
  ratio = min(target / image.width, target / image.height)
184
+ image = image.resize(
185
+ (int(image.width * ratio), int(image.height * ratio)),
186
+ Image.LANCZOS
187
+ )
188
+
189
+ # Convert PIL RGB -> numpy BGR for DDColor
190
  img_rgb = np.array(image)
 
191
  img_bgr = img_rgb[:, :, ::-1]
192
+ # [:,:,::-1] reverses channel order: RGB -> BGR
193
 
194
+ result = colorizer(img_bgr)
195
+ output_bgr = result["output_img"]
196
+ # output_bgr = colorized BGR numpy array, same size as input
197
 
 
198
  output_rgb = output_bgr[:, :, ::-1]
199
+ # Reverse back: BGR -> RGB for PIL
200
 
 
201
  if strength < 1.0:
202
+ grey = np.array(image.convert("L"))
 
203
  grey_3ch = np.stack([grey, grey, grey], axis=-1)
204
+ # Stack single greyscale channel 3x to match (h,w,3) shape
 
205
  output_rgb = (
206
+ strength * output_rgb.astype(float) +
207
  (1 - strength) * grey_3ch.astype(float)
208
  ).astype(np.uint8)
209
+ # Linear blend: strength=0.5 -> 50% color + 50% grey
 
 
210
 
211
+ # BUG 2 FIX: return is now OUTSIDE the if block
212
+ # Previous code returned inside "if strength < 1.0" only
213
+ # At strength=1.0 (slider max), function returned None -> crash
214
+ return Image.fromarray(output_rgb)
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
+ # ================================================================
218
+ # OUTPAINTING CORE
219
+ # ================================================================
220
 
221
  def extend_one_side(image, direction, pixels, prompt, negative_prompt):
222
+ # Extends image by 'pixels' in 'direction'
223
+ # Called in 64px steps small steps give SD max context
 
 
 
 
 
 
224
 
225
+ orig_w = image.width
226
+ orig_h = image.height
227
+ new_w = orig_w
228
+ new_h = orig_h
229
  paste_x = 0
230
  paste_y = 0
231
 
232
  if direction == "left":
233
  new_w = orig_w + pixels
234
  paste_x = pixels
 
235
  elif direction == "right":
236
  new_w = orig_w + pixels
 
 
237
  elif direction == "top":
238
  new_h = orig_h + pixels
239
  paste_y = pixels
 
240
  elif direction == "bottom":
241
  new_h = orig_h + pixels
 
242
 
243
+ # Round to multiple of 8 — SD UNet requirement
244
  new_w = (new_w // 8) * 8
245
  new_h = (new_h // 8) * 8
246
 
247
+ # Recalculate pixels and paste positions after rounding
248
  if direction in ["left", "right"]:
249
  pixels = new_w - orig_w
250
  else:
 
255
  if direction == "top":
256
  paste_y = pixels
257
 
258
+ # Black canvas with original pasted at correct position
259
  canvas = Image.new("RGB", (new_w, new_h), (0, 0, 0))
260
  canvas.paste(image, (paste_x, paste_y))
261
 
262
+ # Mask: white=generate, black=keep original
263
  mask = Image.new("L", (new_w, new_h), 255)
 
264
  draw = ImageDraw.Draw(mask)
265
+ # BUG 6 FIX: removed redundant "from PIL import ImageDraw" inside function
266
+ # Already imported at top of file
267
 
268
  feather = 30
269
+ # 30px margin gives GaussianBlur room to create soft gradient
 
 
270
 
271
  if direction == "left":
272
+ draw.rectangle([paste_x + feather, feather, new_w - feather, new_h - feather], fill=0)
 
 
 
 
273
  elif direction == "right":
274
+ draw.rectangle([feather, feather, orig_w - feather, new_h - feather], fill=0)
 
 
 
 
275
  elif direction == "top":
276
+ draw.rectangle([feather, paste_y + feather, new_w - feather, new_h - feather], fill=0)
 
 
 
 
277
  elif direction == "bottom":
278
+ draw.rectangle([feather, feather, new_w - feather, orig_h - feather], fill=0)
 
 
 
279
 
280
+ # GaussianBlur creates real feathering — soft gradient at boundary
281
+ # Hard edge = visible seam. Soft gradient = seamless blend.
282
+ mask = mask.filter(ImageFilter.GaussianBlur(radius=30))
 
283
 
284
+ # SD2 native resolution = 768x768
285
  sd_size = 768
286
  canvas_sd = canvas.resize((sd_size, sd_size), Image.LANCZOS)
287
  mask_sd = mask.resize((sd_size, sd_size), Image.LANCZOS)
288
+ # LANCZOS preserves soft gradient (NEAREST would destroy it)
289
 
290
  result = inpaint(
291
+ prompt=prompt, image=canvas_sd, mask_image=mask_sd,
292
+ height=sd_size, width=sd_size,
293
+ num_inference_steps=40, guidance_scale=7.0,
294
+ negative_prompt=negative_prompt,
 
 
 
 
295
  )
296
 
297
+ generated_full = result.images[0].resize((new_w, new_h), Image.LANCZOS)
298
+ # No hard paste — feathered mask handles boundary softly
 
299
  return generated_full
300
 
301
+
302
+ # ================================================================
303
+ # OUTPAINTING MAIN
304
+ # ================================================================
305
+
306
  @spaces.GPU
307
  def outpaint_image(image, direction, extend_percent, custom_prompt, progress=gr.Progress()):
308
 
309
  if image is None:
310
  raise gr.Error("Please upload an image first.")
311
+
312
  inpaint.to("cuda")
313
  blip_model.to("cuda")
314
+ # Move to GPU inside @spaces.GPU — ZeroGPU has allocated GPU here
315
 
 
316
  max_side = 512
317
  ratio = min(max_side / image.width, max_side / image.height)
318
+ image = image.resize(
319
+ (int(image.width * ratio), int(image.height * ratio)),
320
+ Image.LANCZOS
321
+ )
322
 
323
  progress(0.05, desc="Analyzing image with BLIP...")
324
 
325
+ blip_caption = custom_prompt.strip() if custom_prompt.strip() else get_caption(image)
 
 
 
326
 
327
+ # BUG 5 FIX: call build_outpaint_prompt with both args
328
+ # Previous code called build_prompt(caption) — one arg, wrong function
329
+ # Lost B&W detection entirely for all greyscale images
330
+ prompt = build_outpaint_prompt(blip_caption, image)
331
 
332
+ base_negative = (
333
  "blurry, bad quality, watermark, text, "
334
  "new person, new face, new subject, extra people, "
 
335
  "duplicate, tiled, repeated pattern, border, frame, "
336
  "seam, visible edge, abrupt change, inconsistent, "
337
  "distorted, unnatural, different style, different era"
338
  )
339
+ negative_prompt = (
340
+ base_negative + ", colorful, vibrant colors, color photography"
341
+ if is_greyscale(image) else base_negative
342
+ )
343
+ # B&W images get extra negative terms to prevent SD adding color
344
 
 
345
  STEP_PX = 64
346
+ extend = extend_percent / 100.0
347
+ h_total = int(image.width * extend)
348
+ v_total = int(image.height * extend)
 
 
 
 
 
 
 
 
349
 
350
  def make_passes(side, total_px):
351
+ passes, remaining = [], total_px
 
 
 
352
  while remaining > 0:
353
  step = min(STEP_PX, remaining)
 
354
  passes.append((side, step))
355
  remaining -= step
356
  return passes
 
 
357
 
 
358
  if direction == "Horizontal":
359
  passes = make_passes("right", h_total) + make_passes("left", h_total)
 
 
360
  elif direction == "Vertical":
361
  passes = make_passes("bottom", v_total) + make_passes("top", v_total)
 
 
362
  else:
 
363
  passes = (
364
+ make_passes("bottom", v_total) + make_passes("top", v_total) +
365
+ make_passes("right", h_total) + make_passes("left", h_total)
 
 
366
  )
367
 
368
  total_passes = len(passes)
369
  current_image = image
370
 
371
  for i, (side, px) in enumerate(passes):
372
+ progress(
373
+ 0.1 + 0.85 * (i / total_passes),
374
+ desc=f"Pass {i+1}/{total_passes} — extending {side} by {px}px"
 
 
 
 
 
 
375
  )
376
+ current_image = extend_one_side(current_image, side, px, prompt, negative_prompt)
 
377
 
378
  progress(1.0, desc="Done!")
379
 
380
+ bw_note = " [B&W detected]" if is_greyscale(image) else ""
381
+ return current_image, f"Caption{bw_note}:\n{blip_caption}\n\nPrompt:\n{prompt}"
 
 
382
 
383
+
384
+ # ================================================================
385
  # GRADIO UI
386
+ # ================================================================
387
+
388
+ with gr.Blocks(title="CanvasAI") as demo:
389
 
390
+ gr.Markdown("# CanvasAI")
391
  gr.Markdown(
392
+ "**Enhance** with Real-ESRGAN | "
393
+ "**Colorize** B&W photos with DDColor | "
394
+ "**Outpaint** to extend any scene with SD2"
395
  )
396
 
397
  with gr.Tabs():
398
 
399
+ with gr.Tab("Enhance"):
400
  gr.Markdown("Upscale and sharpen any image 2x or 4x using Real-ESRGAN.")
401
  with gr.Row():
402
  with gr.Column():
403
  enh_input = gr.Image(label="Upload Image", type="pil")
404
  enh_scale = gr.Dropdown(
405
+ choices=["2x", "4x"], value="4x", label="Upscale Factor",
406
+ info="4x recommended. Use 2x for very large inputs."
 
407
  )
408
  enh_btn = gr.Button("Enhance", variant="primary")
409
  with gr.Column():
410
  enh_output = gr.Image(label="Result", type="pil", interactive=False)
411
  enh_info = gr.Textbox(label="Size Info", interactive=False)
412
 
413
+ enh_btn.click(fn=enhance_image, inputs=[enh_input, enh_scale], outputs=[enh_output, enh_info])
 
 
 
 
414
 
415
+ with gr.Tab("Colorize"):
416
+ gr.Markdown(
417
+ "Upload a black and white image. "
418
+ "DDColor adds natural, realistic colors while preserving the original structure."
419
+ )
420
  with gr.Row():
421
  with gr.Column():
422
+ col_input = gr.Image(label="Upload B&W Image", type="pil")
423
+ col_strength = gr.Slider(
424
+ minimum=0.5, maximum=1.0, value=0.9, step=0.05,
425
+ label="Color Strength",
426
+ # BUG 10 FIX: maximum changed from 0.7 to 1.0
427
+ # User can now reach full DDColor output at 1.0
428
+ info="0.5 = subtle tint. 1.0 = full vivid DDColor output."
 
 
 
 
 
 
 
 
429
  )
430
+ col_btn = gr.Button("Colorize", variant="primary")
431
  with gr.Column():
432
+ col_output = gr.Image(label="Colorized Result", type="pil", interactive=False)
433
+
434
+ col_btn.click(fn=colour_image, inputs=[col_input, col_strength], outputs=[col_output])
 
 
 
435
 
436
+ with gr.Tab("Outpaint"):
437
  gr.Markdown(
438
  "Upload an image and extend it in any direction. "
439
  "BLIP reads the scene automatically — no prompt needed."
440
  )
441
  with gr.Row():
442
  with gr.Column():
443
+ out_input = gr.Image(label="Upload Image", type="pil")
444
+ out_dir = gr.Radio(
445
+ choices=["Horizontal", "Vertical", "Both"], value="Horizontal",
446
+ label="Extension Direction",
447
+ info="Horizontal = left & right | Vertical = top & bottom | Both = all sides"
 
 
 
 
 
448
  )
449
+ out_pct = gr.Slider(minimum=10, maximum=50, value=25, step=5, label="Extend by (%)")
450
  out_prompt = gr.Textbox(
451
  label="Custom Prompt (optional)",
452
+ placeholder="Leave empty BLIP reads your image automatically",
453
  lines=2
454
  )
455
+ out_btn = gr.Button("Outpaint", variant="primary")
456
  with gr.Column():
457
  out_output = gr.Image(label="Result", type="pil", interactive=False)
458
  out_caption = gr.Textbox(label="Prompt Used", interactive=False, lines=4)
 
463
  outputs=[out_output, out_caption]
464
  )
465
 
 
466
  demo.launch()
requirements.txt CHANGED
@@ -5,7 +5,10 @@ transformers
5
  accelerate
6
  Pillow
7
  numpy
 
8
  basicsr
9
  facexlib
10
  gfpgan
11
- realesrgan@ git+https://github.com/xinntao/Real-ESRGAN.git
 
 
 
5
  accelerate
6
  Pillow
7
  numpy
8
+ opencv-python
9
  basicsr
10
  facexlib
11
  gfpgan
12
+ realesrgan@ git+https://github.com/xinntao/Real-ESRGAN.git
13
+ modelscope
14
+ spaces