# ================================================================ # 1. ZeroGPU REQUIRES `spaces` TO BE THE FIRST IMPORT # ================================================================ import spaces # MUST be first import os import gc import sys import math import time import types import cv2 import urllib.request import shutil import tempfile import warnings import torch import numpy as np import gradio as gr from PIL import Image, ImageFilter, ImageDraw, ImageEnhance # Ignore non-critical warnings warnings.filterwarnings("ignore", category=ResourceWarning) warnings.filterwarnings("ignore", category=UserWarning) # Torchvision shim for older dependency paths try: import torchvision.transforms.functional_tensor # noqa: F401 except ModuleNotFoundError: import torchvision.transforms.functional as _F _shim = types.ModuleType("torchvision.transforms.functional_tensor") _shim.rgb_to_grayscale = _F.rgb_to_grayscale sys.modules["torchvision.transforms.functional_tensor"] = _shim from torchvision.transforms.functional import normalize from diffusers import FluxFillPipeline, FluxTransformer2DModel, FluxKontextPipeline from transformers import T5EncoderModel, CLIPModel, CLIPProcessor from basicsr.archs.rrdbnet_arch import RRDBNet from realesrgan import RealESRGANer from gfpgan import GFPGANer # Optional quality metrics imports try: from piq import brisque except ImportError: brisque = None # ================================================================ # 2. LOAD FLUX.1-Fill-dev NF4 # ================================================================ try: from diffusers.quantizers.bitsandbytes.bnb_quantizer import BnB4BitDiffusersQuantizer def _check_quantized_param_shape(self, param_name, current_param, loaded_param): n = math.prod(tuple(current_param.shape)) inferred_shape = (n,) if "bias" in param_name else ((n + 1) // 2, 1) if tuple(loaded_param.shape) != tuple(inferred_shape): raise ValueError( f"Expected flattened shape of ({param_name}) " f"to be {tuple(loaded_param.shape)} but got {tuple(inferred_shape)}." ) return True BnB4BitDiffusersQuantizer.check_quantized_param_shape = _check_quantized_param_shape print("āœ… bnb shape-check shim applied.") except Exception as _exc: print(f"[startup] bnb shape-check shim not applied: {_exc!r}") print("Loading FLUX.1-Fill-dev NF4...") base_id = "black-forest-labs/FLUX.1-Fill-dev" nf4_id = "sayakpaul/FLUX.1-Fill-dev-nf4" transformer = FluxTransformer2DModel.from_pretrained( nf4_id, subfolder="transformer", torch_dtype=torch.bfloat16 ) text_encoder_2 = T5EncoderModel.from_pretrained( nf4_id, subfolder="text_encoder_2", torch_dtype=torch.bfloat16 ) pipe = FluxFillPipeline.from_pretrained( base_id, transformer=transformer, text_encoder_2=text_encoder_2, torch_dtype=torch.bfloat16, ) pipe.to("cuda") pipe.vae.enable_slicing() pipe.vae.enable_tiling() print("FLUX.1-Fill loaded.") # ================================================================ # 3. LOAD GFPGAN & REAL-ESRGAN (Photo & Face Restoration) # ================================================================ print("Setting up GFPGAN & Real-ESRGAN...") weights_dir = "weights" os.makedirs(weights_dir, exist_ok=True) # Download into a temporary file and atomically publish only a complete weight file. def ensure_weight_file(path, url, min_bytes=1_000_000): if os.path.isfile(path) and os.path.getsize(path) >= min_bytes: return os.makedirs(os.path.dirname(path) or ".", exist_ok=True) fd, temp_path = tempfile.mkstemp(prefix=".download-", dir=os.path.dirname(path) or ".") os.close(fd) try: with urllib.request.urlopen(url, timeout=60) as response, open(temp_path, "wb") as out: shutil.copyfileobj(response, out) if os.path.getsize(temp_path) < min_bytes: raise RuntimeError(f"Downloaded weight file is unexpectedly small: {path}") os.replace(temp_path, path) finally: if os.path.exists(temp_path): os.remove(temp_path) # 1. Real-ESRGAN Weights Setup esrgan_path = f"{weights_dir}/RealESRGAN_x4plus.pth" ensure_weight_file( esrgan_path, "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth", ) esrgan_model = RRDBNet( num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32, scale=4 ) restoration_device = torch.device("cuda" if torch.cuda.is_available() else "cpu") enhancer = RealESRGANer( scale=4, model_path=esrgan_path, model=esrgan_model, tile=512, tile_pad=10, pre_pad=0, half=(restoration_device.type == "cuda"), device=restoration_device, ) # 2. GFPGAN Weights Setup gfpgan_path = f"{weights_dir}/GFPGANv1.3.pth" ensure_weight_file( gfpgan_path, "https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.3.pth", ) gfpgan_restorer = GFPGANer( model_path=gfpgan_path, upscale=1, # Set base upscale to 1, as Real-ESRGAN handles base canvas scaling arch="clean", channel_multiplier=2, bg_upsampler=enhancer, device=restoration_device ) print("GFPGAN & Real-ESRGAN ready.") # ================================================================ # 4. FLUX.1-Kontext + ColorizeTruer LoRA # ================================================================ print("Loading FLUX.1-Kontext NF4...") kontext_pipe = FluxKontextPipeline.from_pretrained( "aniketppanchal/flux.1-kontext-dev-nf4-pkg", torch_dtype=torch.bfloat16, device_map="cuda", ) kontext_pipe.load_lora_weights( "AlekseyCalvin/ColorizeTruer_Merge5wRef_KontexFlux_LoRA_bySAP" ) print("āœ… FLUX.1-Kontext NF4 loaded.") # ================================================================ # 5. LOAD CLIP # ================================================================ print("Loading CLIP for metrics...") clip_model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32").to("cpu") clip_processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32") print("All models loaded successfully.") # ================================================================ # 6. HELPER FUNCTIONS # ================================================================ def is_greyscale_or_sepia(image): rgb = np.asarray(image.convert("RGB"), dtype=np.uint8) hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) mean_sat = float(np.mean(hsv[:, :, 1])) is_greyscale = mean_sat < 25 # A simple warm-channel heuristic; intentionally avoid classifying grayscale # images as sepia. This is a prompt hint, not a strict image classifier. channel_means = rgb.mean(axis=(0, 1)) red_mean, green_mean, blue_mean = channel_means is_sepia = ( not is_greyscale and red_mean > green_mean + 8 and green_mean > blue_mean + 5 ) return is_greyscale, bool(is_sepia) def calculate_padding(ow, oh, direction, pct): extend = pct / 100.0 h_ext, v_ext = int(ow * extend), int(oh * extend) left = top = right = bottom = 0 if direction == "Left": left = h_ext elif direction == "Right": right = h_ext elif direction == "Top": top = v_ext elif direction == "Bottom": bottom = v_ext elif direction == "Horizontal": left = right = h_ext elif direction == "Vertical": top = bottom = v_ext elif direction in ["Both", "All Sides"]: left = right = h_ext top = bottom = v_ext return left, top, right, bottom def blur_mask(mask_pil, radius=16): return mask_pil.filter(ImageFilter.GaussianBlur(radius=radius)) def cap_image(image, max_side=1024): if max(image.size) <= max_side: return image ratio = max_side / max(image.size) return image.resize((int(image.width * ratio), int(image.height * ratio)), Image.LANCZOS) def _colorfulness(image): rgb = np.array(image.convert("RGB")) R, G, B = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2] rg = np.absolute(R - G) yb = np.absolute(0.5 * (R + G) - B) std_rg, mean_rg = np.std(rg), np.mean(rg) std_yb, mean_yb = np.std(yb), np.mean(yb) return np.sqrt(std_rg**2 + std_yb**2) + 0.3 * np.sqrt(mean_rg**2 + mean_yb**2) # ================================================================ # 7. ENHANCEMENT FUNCTION (PURE RESTORATION & UPSCALING) # ================================================================ @spaces.GPU(duration=60) def enhance_image(image, scale_factor, mode="Pure Super-Resolution (Safe for Group Photos)", fidelity_weight=0.15, compute_metrics=False): if image is None: raise gr.Error("Please upload an image first.") gc.collect() torch.cuda.empty_cache() if max(image.size) > 1200: image = cap_image(image, 1200) rgb_np = np.array(image.convert("RGB"), dtype=np.uint8) input_bgr = cv2.cvtColor(rgb_np, cv2.COLOR_RGB2BGR) target_scale = 4 if scale_factor == "4x" else 2 try: if "Pure Super-Resolution" in mode: # Pure Real-ESRGAN Upscaling & Deblurring (preserves exact facial features and group structures) output_bgr, _ = enhancer.enhance(input_bgr, outscale=target_scale) else: # GFPGAN Face Deblurring + Real-ESRGAN Canvas Upscaling gfpgan_restorer.upscale = target_scale _, _, output_bgr = gfpgan_restorer.enhance( input_bgr, has_aligned=False, only_center_face=False, paste_back=True, weight=float(fidelity_weight) ) output_rgb = cv2.cvtColor(output_bgr, cv2.COLOR_BGR2RGB) output_image = Image.fromarray(output_rgb) except Exception as e: raise gr.Error(f"Restoration failed: {e}") finally: gc.collect() torch.cuda.empty_cache() info = f"Restored {output_image.width}x{output_image.height}px using {mode}" if compute_metrics: try: info += f"\nšŸŽØ Colorfulness: {_colorfulness(output_image):.2f}" if brisque is not None: from torchvision import transforms t = transforms.ToTensor()(output_image).unsqueeze(0).to("cuda") info += f"\nšŸ“‰ BRISQUE: {brisque(t, data_range=1.0).item():.2f} (lower better)" except Exception as err: info += f"\nāš ļø Metrics error: {err}" return output_image, info # ================================================================ # 8. COLORIZATION # ================================================================ @spaces.GPU(duration=58) def colour_image(image, strength, compute_metrics=False): if image is None: raise gr.Error("Please upload an image first.") image = cap_image(image, 1024) original_image = image.convert("RGB") ow, oh = original_image.size target_w = int(16 * math.ceil(ow / 16)) target_h = int(16 * math.ceil(oh / 16)) resized_input = original_image.resize((target_w, target_h), Image.LANCZOS) prompt = "colorize this black and white photograph with rich, natural, vivid color tones." try: with torch.inference_mode(): output = kontext_pipe( image=resized_input, prompt=prompt, height=target_h, width=target_w, num_inference_steps=20, guidance_scale=3.5, max_sequence_length=512, ).images[0] output_aligned = output.resize((ow, oh), Image.LANCZOS) output_rgb = np.array(output_aligned) if strength < 1.0: grey = np.array(original_image.convert("L")) grey_3ch = np.stack([grey, grey, grey], axis=-1) output_rgb = ( strength * output_rgb.astype(float) + (1.0 - strength) * grey_3ch.astype(float) ).astype(np.uint8) out_img = Image.fromarray(output_rgb) info = "" if compute_metrics: try: cf_in = _colorfulness(original_image) cf_out = _colorfulness(out_img) info += f"šŸŽØ Colorfulness: {cf_in:.2f} → {cf_out:.2f} (Ī” {cf_out-cf_in:+.2f})\n" if brisque is not None: from torchvision import transforms t = transforms.ToTensor()(out_img).unsqueeze(0).to("cuda") info += f"šŸ“‰ BRISQUE: {brisque(t, data_range=1.0).item():.2f} (lower better)" except Exception as e: info += f"āš ļø Metrics error: {e}" return out_img, info finally: gc.collect() torch.cuda.empty_cache() # ================================================================ # 9. OUTPAINTING # ================================================================ @spaces.GPU(duration=120) def fast_single_pass_outpaint( image, direction="Horizontal", extend_percent=25, custom_prompt="", custom_override=False, seed=42, randomize_seed=True, guidance_scale=3.5, steps=20, compute_metrics=False, progress=gr.Progress() ): if image is None: raise gr.Error("Please upload an image first.") start_time = time.time() steps = int(steps) if not 10 <= steps <= 50: raise gr.Error("Inference steps must be between 10 and 50.") image = cap_image(image, 1024) ow, oh = image.size progress(0.05, desc="Analyzing scene color and style...") bw, sepia = is_greyscale_or_sepia(image) if custom_override and custom_prompt and custom_prompt.strip(): prompt = custom_prompt.strip() else: prompt = "Seamless background environment extension, natural scenery, outdoor landscape, empty background." if custom_prompt and custom_prompt.strip(): prompt += f" {custom_prompt.strip()}" if bw: prompt += " Black and white, vintage monochrome, matching film grain." elif sepia: prompt += " Warm sepia tones." else: prompt += " Photorealistic, vivid natural colors, matching original lighting." progress(0.10, desc="Preparing padded canvas...") left, top, right, bottom = calculate_padding(ow, oh, direction, extend_percent) target_w = ow + left + right target_h = oh + top + bottom target_w = int(16 * math.ceil(target_w / 16)) target_h = int(16 * math.ceil(target_h / 16)) # Bound worst-case FLUX memory use. Raise this only after validating the # selected ZeroGPU allocation can handle larger canvases reliably. max_outpaint_side = 2048 max_outpaint_pixels = 4_194_304 if ( target_w > max_outpaint_side or target_h > max_outpaint_side or target_w * target_h > max_outpaint_pixels ): raise gr.Error( f"Requested canvas is too large ({target_w}x{target_h}). " f"Reduce the extension percentage or use a smaller input image." ) canvas = Image.new("RGB", (target_w, target_h), (127, 127, 127)) canvas.paste(image, (left, top)) mask = Image.new("L", (target_w, target_h), 255) draw = ImageDraw.Draw(mask) draw.rectangle([left, top, left + ow - 1, top + oh - 1], fill=0) feathered_mask = blur_mask(mask, radius=16) if randomize_seed or seed is None or seed < 0: seed = int(np.random.randint(0, 2**32 - 1)) else: seed = int(seed) generator = torch.Generator("cuda").manual_seed(seed) try: progress(0.20, desc="Generating background extension with FLUX Fill...") with torch.inference_mode(): result = pipe( prompt=prompt, image=canvas, mask_image=feathered_mask, height=target_h, width=target_w, max_sequence_length=512, num_inference_steps=steps, guidance_scale=guidance_scale, generator=generator, ).images[0] mask_inv = Image.eval(feathered_mask, lambda p: 255 - p) mask_inv_cropped = mask_inv.crop((left, top, left + ow, top + oh)) result.paste(image, (left, top), mask_inv_cropped) final_w = ow + left + right final_h = oh + top + bottom current_img = result.crop((0, 0, final_w, final_h)) total_time = time.time() - start_time final_info = ( f"ā±ļø Time: {total_time:.2f} sec\n" f"šŸ“ Final: {current_img.width}x{current_img.height}\n" f"šŸ”¢ Seed: {seed}\n" f"šŸ“ Prompt: {prompt}\n" f"šŸ” Passes: Single Pass\n" ) if compute_metrics: progress(0.92, desc="Computing quality metrics...") try: cf = _colorfulness(current_img) final_info += f"\nšŸŽØ Colorfulness: {cf:.2f}" if brisque is not None: from torchvision import transforms t = transforms.ToTensor()(current_img).unsqueeze(0).to("cuda") bs = brisque(t, data_range=1.0).item() final_info += f"\nšŸ“‰ BRISQUE: {bs:.2f} (lower better)" try: clip_model.to("cuda") with torch.no_grad(): inputs = clip_processor( text=[prompt[:77]], images=current_img, return_tensors="pt", padding=True, truncation=True ).to("cuda") clip_score = clip_model(**inputs).logits_per_image.item() final_info += f"\nšŸ¤– CLIP: {clip_score:.4f} (higher better)" finally: # Keep this large optional metrics model off the GPU even # when preprocessing or scoring fails. clip_model.to("cpu") except Exception as e: final_info += f"\nāš ļø Metrics error: {e}" progress(1.0, desc="Done!") return current_img, canvas, feathered_mask, final_info finally: gc.collect() torch.cuda.empty_cache() # ================================================================ # 10. TAB HELPERS & GRADIO UI # ================================================================ def send_to_outpaint(img): return (img, gr.update(selected="tab_outpaint")) if img is not None else (None, gr.update(selected="tab_outpaint")) def send_to_enhance(img): return (img, gr.update(selected="tab_enhance")) if img is not None else (None, gr.update(selected="tab_enhance")) with gr.Blocks( title="CanvasAI", theme=gr.themes.Soft( primary_hue=gr.themes.colors.violet, secondary_hue=gr.themes.colors.purple, neutral_hue=gr.themes.colors.slate, radius_size=gr.themes.sizes.radius_sm, ) ) as demo: gr.Markdown("# CanvasAI\n*AI-powered image enhancement, colorization, and outpainting*") gr.Markdown("**Enhance** with GFPGAN & Real-ESRGAN | **Colorize** B&W photos | **Outpaint** with FLUX.1-Fill") gr.Markdown("---") with gr.Tabs() as tabs: # Colorize Tab with gr.Tab("šŸŽØ Colorize", id="tab_colorize"): gr.Markdown("## B&W Photo Colorization\nFLUX.1-Kontext + ColorizeTruer LoRA.") with gr.Row(equal_height=True): with gr.Column(scale=1): col_input = gr.Image(label="Upload B&W Image", type="pil", height=350) col_strength = gr.Slider(minimum=0.5, maximum=1.0, value=0.9, step=0.05, label="Color Strength") col_metrics = gr.Checkbox(label="šŸ“Š Compute quality metrics", value=False) col_btn = gr.Button("Colorize", variant="primary", size="lg") with gr.Column(scale=1): col_output = gr.Image(label="Colorized Result", type="pil", interactive=False, height=350) col_info = gr.Textbox(label="Metrics", interactive=False, lines=3) with gr.Row(): btn_col_to_outpaint = gr.Button("āž”ļø Send to Outpaint", variant="secondary") btn_col_to_enhance = gr.Button("✨ Send to Enhance", variant="secondary") col_btn.click(fn=colour_image, inputs=[col_input, col_strength, col_metrics], outputs=[col_output, col_info]) # Outpaint Tab with gr.Tab("šŸ–¼ Outpaint", id="tab_outpaint"): gr.Markdown("## FLUX Outpainting Engine\nExpand image boundaries naturally using single-pass FLUX Fill without subject duplication.") with gr.Row(): with gr.Column(scale=1): out_input = gr.Image(type="pil", label="Input Image") direction = gr.Dropdown( choices=["Horizontal", "Vertical", "Left", "Right", "Top", "Bottom", "Both"], value="Horizontal", label="Expansion Direction" ) extend_percent = gr.Slider(minimum=10, maximum=100, step=5, value=25, label="Extension Percentage (%)") with gr.Accordion("Advanced Prompting Options", open=False): custom_prompt = gr.Textbox(label="Additional Prompt Directives", placeholder="e.g. dense forest, snowy mountains...") custom_override = gr.Checkbox(label="Override Auto-Prompt Completely", value=False) with gr.Accordion("Inference Hyperparameters", open=False): guidance_scale = gr.Slider(minimum=1.0, maximum=15.0, step=0.5, value=3.5, label="Guidance Scale (Keep low ~3.5)") steps = gr.Slider(minimum=10, maximum=50, step=1, value=20, label="Inference Steps") seed = gr.Number(value=42, label="Seed", precision=0) randomize_seed = gr.Checkbox(label="Randomize Seed", value=True) compute_metrics = gr.Checkbox(label="Calculate Quality Metrics", value=False) run_btn = gr.Button("Outpaint Image", variant="primary") with gr.Column(scale=1): out_output = gr.Image(label="Outpainted Result") info_output = gr.Textbox(label="Execution Info & Metrics", lines=6) btn_out_to_enhance = gr.Button("✨ Send to Enhance", variant="secondary") with gr.Accordion("Debug Canvas & Feathered Mask", open=False): debug_canvas = gr.Image(label="Padded Canvas") debug_mask = gr.Image(label="Feathered Mask") run_btn.click( fn=fast_single_pass_outpaint, inputs=[ out_input, direction, extend_percent, custom_prompt, custom_override, seed, randomize_seed, guidance_scale, steps, compute_metrics ], outputs=[out_output, debug_canvas, debug_mask, info_output] ) # Enhance Tab UI with gr.Tab("✨ Enhance", id="tab_enhance"): gr.Markdown("## Restore old blurry photos, sharpen features, and upscale up to 4x.") with gr.Row(equal_height=True): with gr.Column(scale=1): enh_input = gr.Image(label="Upload Image", type="pil", height=350) enh_mode = gr.Radio( choices=[ "Pure Super-Resolution (Safe for Group Photos)", "Subtle Face Refinement (For Close-ups)" ], value="Pure Super-Resolution (Safe for Group Photos)", label="Restoration Mode" ) enh_scale = gr.Radio(choices=["2x", "4x"], value="4x", label="Upscale Factor") enh_weight = gr.Slider( minimum=0.0, maximum=0.5, value=0.15, step=0.05, label="Face Restoration Weight (Only applies in Refinement mode; keep low <= 0.2)" ) enh_metrics = gr.Checkbox(label="šŸ“Š Compute quality metrics", value=False) enh_btn = gr.Button("Enhance Image", variant="primary", size="lg") with gr.Column(scale=1): enh_output = gr.Image(label="Result", type="pil", interactive=False, height=350) enh_info = gr.Textbox(label="Size Info + Metrics", interactive=False, lines=4) enh_btn.click( fn=enhance_image, inputs=[enh_input, enh_scale, enh_mode, enh_weight, enh_metrics], outputs=[enh_output, enh_info] ) # Inter-tab Event Routing Connections btn_col_to_outpaint.click(fn=send_to_outpaint, inputs=[col_output], outputs=[out_input, tabs]) btn_col_to_enhance.click(fn=send_to_enhance, inputs=[col_output], outputs=[enh_input, tabs]) btn_out_to_enhance.click(fn=send_to_enhance, inputs=[out_output], outputs=[enh_input, tabs]) demo.queue().launch(show_error=True)