Spaces:
Running
Running
| import os | |
| import sys | |
| import subprocess | |
| import time | |
| import requests | |
| import json | |
| import gradio as gr | |
| from PIL import Image | |
| import spaces | |
| from huggingface_hub import hf_hub_download | |
| os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "1" | |
| # Suppress unnecessary warnings | |
| os.environ["PYTHONWARNINGS"] = "ignore" | |
| os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" | |
| # Configuration | |
| REPO_URL = "https://github.com/00quebec/Synthid-Bypass" | |
| COMFYUI_URL = "https://github.com/comfyanonymous/ComfyUI" | |
| PYTHON_EXTENSION_URL = "https://github.com/pydn/ComfyUI-to-Python-Extension" | |
| ROOT_DIR = os.getcwd() | |
| COMFYUI_DIR = os.path.join(ROOT_DIR, "ComfyUI") | |
| BYPASS_REPO_DIR = os.path.join(ROOT_DIR, "reference_repo") | |
| def setup(): | |
| """Environment setup for Hugging Face Space""" | |
| # Check if a known model file exists to skip setup | |
| if os.path.exists(os.path.join(COMFYUI_DIR, "models/vae/ae.safetensors")): | |
| print("--- ENVIRONMENT ALREADY INITIALIZED ---") | |
| return | |
| print("--- FIRST TIME SETUP STARTING ---") | |
| # 1. Clone Repos | |
| subprocess.run(["git", "clone", COMFYUI_URL, COMFYUI_DIR], check=True, capture_output=True) | |
| subprocess.run(["git", "clone", REPO_URL, BYPASS_REPO_DIR], check=True, capture_output=True) | |
| # 2. Setup Custom Nodes | |
| nodes = [ | |
| "https://github.com/ltdrdata/ComfyUI-Impact-Pack", | |
| "https://github.com/ltdrdata/ComfyUI-Impact-Subpack", | |
| "https://github.com/wildminder/ComfyUI-dype", | |
| "https://github.com/rgthree/rgthree-comfy", | |
| "https://github.com/BadCafeCode/masquerade-nodes-comfyui", | |
| "https://github.com/lquesada/ComfyUI-Inpaint-CropAndStitch", | |
| "https://github.com/numz/ComfyUI-SeedVR2_VideoUpscaler", | |
| PYTHON_EXTENSION_URL | |
| ] | |
| custom_nodes_path = os.path.join(COMFYUI_DIR, "custom_nodes") | |
| os.makedirs(custom_nodes_path, exist_ok=True) | |
| # Pin Impact packs to exact versions used in reference workflow | |
| IMPACT_PACK_COMMIT = "61bd8397a18e7e7668e6a24e95168967768c2bed" | |
| IMPACT_SUBPACK_VERSION = "1.3.4" # Using 1.3.4 (latest available, ref workflow uses "1.3.5" which doesn't exist as tag) | |
| for url in nodes: | |
| name = url.split("/")[-1] | |
| node_dest = os.path.join(custom_nodes_path, name) | |
| if not os.path.exists(node_dest): | |
| subprocess.run(["git", "clone", url, node_dest], check=True, capture_output=True) | |
| # Checkout specific versions for Impact packs to match reference workflow | |
| if name == "ComfyUI-Impact-Pack": | |
| subprocess.run(["git", "checkout", IMPACT_PACK_COMMIT], cwd=node_dest, check=True, capture_output=True) | |
| elif name == "ComfyUI-Impact-Subpack": | |
| subprocess.run(["git", "checkout", IMPACT_SUBPACK_VERSION], cwd=node_dest, check=True, capture_output=True) | |
| print("✓ Custom nodes installed") | |
| # Install performance optimizations (SageAttention, Flash Attention) | |
| print("Installing performance optimizations...") | |
| subprocess.run([ | |
| sys.executable, "-m", "pip", "install", | |
| "sageattention", "flash-attn", "--no-cache-dir" | |
| ], capture_output=True, check=False) # Don't fail if these can't install | |
| # 3. Models Download logic (Using hf_transfer for speed) | |
| model_configs = [ | |
| {"repo": "Comfy-Org/z_image_turbo", "file": "split_files/vae/ae.safetensors", "dest": "models/vae/ae.safetensors"}, | |
| {"repo": "Comfy-Org/z_image_turbo", "file": "split_files/diffusion_models/z_image_turbo_bf16.safetensors", "dest": "models/diffusion_models/z_image_turbo_bf16.safetensors"}, | |
| {"repo": "Comfy-Org/z_image_turbo", "file": "split_files/text_encoders/qwen_3_4b.safetensors", "dest": "models/text_encoders/qwen_3_4b.safetensors"}, | |
| {"repo": "alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union", "file": "Z-Image-Turbo-Fun-Controlnet-Union.safetensors", "dest": "models/model_patches/Z-Image-Turbo-Fun-Controlnet-Union.safetensors"}, | |
| {"repo": "deepghs/yolo-face", "file": "yolov8n-face/model.pt", "dest": "models/ultralytics/bbox/yolov8n-face.pt"}, | |
| {"repo": "YouLiXiya/YL-SAM", "file": "sam_vit_b_01ec64.pth", "dest": "models/sams/sam_vit_b_01ec64.pth"}, | |
| # SeedVR2 models (15.3GB DiT + 672MB VAE) | |
| {"repo": "numz/SeedVR2_comfyUI", "file": "seedvr2_ema_7b_sharp_fp16.safetensors", "dest": "models/SEEDVR2/seedvr2_ema_7b_sharp_fp16.safetensors"}, | |
| {"repo": "numz/SeedVR2_comfyUI", "file": "ema_vae_fp16.safetensors", "dest": "models/SEEDVR2/ema_vae_fp16.safetensors"} | |
| ] | |
| print("Downloading models (fast with HF_TRANSFER)...") | |
| for i, cfg in enumerate(model_configs, 1): | |
| out_path = os.path.join(COMFYUI_DIR, cfg['dest']) | |
| if not os.path.exists(out_path): | |
| os.makedirs(os.path.dirname(out_path), exist_ok=True) | |
| print(f" [{i}/{len(model_configs)}] {cfg['file'].split('/')[-1]}") | |
| hf_hub_download( | |
| repo_id=cfg['repo'], | |
| filename=cfg['file'], | |
| local_dir=COMFYUI_DIR, | |
| local_dir_use_symlinks=False | |
| ) | |
| actual_downloaded_path = os.path.join(COMFYUI_DIR, cfg['file']) | |
| if actual_downloaded_path != out_path and os.path.exists(actual_downloaded_path): | |
| os.rename(actual_downloaded_path, out_path) | |
| print("✓ Setup complete") | |
| def convert_to_api(web_workflow): | |
| """ | |
| Robustly converts ComfyUI Web JSON (UI format) to API Prompt format. | |
| Requires mapping links to actual node connections. | |
| """ | |
| nodes = web_workflow.get("nodes", []) | |
| links = web_workflow.get("links", []) | |
| # Map link_id -> [origin_node_id, origin_slot_index] | |
| link_map = {} | |
| for link in links: | |
| if link: | |
| l_id, node_from, slot_from, node_to, slot_to, l_type = link | |
| link_map[l_id] = [str(node_from), slot_from] | |
| api_prompt = {} | |
| skipped_nodes = [] | |
| for node in nodes: | |
| node_id = str(node["id"]) | |
| class_type = node["type"] | |
| # Skip UI-only nodes, provider/loader nodes, and primitive types | |
| skip_types = ["Note", "Group", "Reroute", "Float", "Int", "String", "Boolean"] | |
| # Also skip any node with "Provider" or "Loader" in the name (these are config nodes) | |
| if class_type in skip_types or "Provider" in class_type or "Loader" in class_type and class_type not in ["UNETLoader", "VAELoader", "CLIPLoader"]: | |
| skipped_nodes.append(f"{node_id}:{class_type}") | |
| continue | |
| inputs = {} | |
| # 1. Handle Connections (from links) | |
| for inp in node.get("inputs", []): | |
| l_id = inp.get("link") | |
| if l_id and l_id in link_map: | |
| inputs[inp["name"]] = link_map[l_id] | |
| # 2. Handle Widgets (from widgets_values) | |
| # This is where it gets tricky since Web format stores values in a list | |
| # and API format expects them as named keys. | |
| # We'll use a known mapping for core nodes if possible. | |
| # For custom nodes, it depends on the node's implementation of 'INPUT_TYPES'. | |
| # Note: If the workflow was saved with 'widgets_values', we inject them. | |
| # We'll try to guess common input names or just pass them as indices if the server allows. | |
| # For SynthID-Bypass, we'll hardcode the critical ones if needed. | |
| # Fallback: Many nodes put widgets after connections in their registration. | |
| # If we don't have names, it might fail. | |
| # However, many modern workflows save 'widgets_values' which we need to map. | |
| # For this specific bypass tool, we'll use the pre-known node names for key nodes. | |
| w_values = node.get("widgets_values", []) | |
| if class_type == "CLIPTextEncode" and w_values: | |
| inputs["text"] = w_values[0] | |
| elif class_type == "KSampler" and len(w_values) >= 7: | |
| inputs["seed"] = w_values[0] | |
| inputs["steps"] = w_values[2] | |
| inputs["cfg"] = w_values[3] | |
| inputs["sampler_name"] = w_values[4] | |
| inputs["scheduler"] = w_values[5] | |
| inputs["denoise"] = w_values[6] | |
| elif class_type == "VAELoader" and w_values: | |
| inputs["vae_name"] = w_values[0] | |
| elif class_type == "UNETLoader" and w_values: | |
| inputs["unet_name"] = w_values[0] | |
| elif class_type == "LoadImage" and w_values: | |
| inputs["image"] = w_values[0] | |
| inputs["upload"] = w_values[1] if len(w_values) > 1 else "image" | |
| elif class_type == "ModelSamplingAuraFlow" and w_values: | |
| inputs["shift"] = w_values[0] | |
| elif class_type == "DyPE_FLUX" and len(w_values) >= 4: | |
| inputs["width"] = w_values[0] | |
| inputs["height"] = w_values[1] | |
| inputs["preset"] = w_values[2] | |
| inputs["pe_type"] = w_values[3] | |
| # Advanced Peet's parameters... usually defaults are okay but we can add more if needed | |
| # Add any other widget values that might be present | |
| # This is a guestimate, but is usually how API conversion works | |
| api_prompt[node_id] = { | |
| "class_type": class_type, | |
| "inputs": inputs | |
| } | |
| print(f"Converted {len(api_prompt)} nodes, skipped {len(skipped_nodes)} nodes: {', '.join(skipped_nodes[:10])}") | |
| return api_prompt | |
| # Execute setup on boot | |
| setup() | |
| def remove_watermark(input_image): | |
| if input_image is None: | |
| return None | |
| # 1. Prepare Paths | |
| input_dir = os.path.join(COMFYUI_DIR, "input") | |
| output_dir = os.path.join(COMFYUI_DIR, "output") | |
| os.makedirs(input_dir, exist_ok=True) | |
| os.makedirs(output_dir, exist_ok=True) | |
| # Save input image with a fixed name for the workflow | |
| input_filename = "input.png" | |
| input_path = os.path.join(input_dir, input_filename) | |
| input_image.save(input_path) | |
| # 2. Launch ComfyUI (Headless) | |
| print("Launching Headless ComfyUI server...") | |
| # Using the correct CWD is critical for ComfyUI to find its models and custom nodes | |
| cmd = [sys.executable, "main.py", "--listen", "127.0.0.1", "--port", "8188", "--disable-auto-launch"] | |
| proc = subprocess.Popen(cmd, cwd=COMFYUI_DIR) | |
| # Wait for server to be ready (increased timeout and added logging) | |
| server_ready = False | |
| for i in range(45): # 90 seconds max | |
| try: | |
| resp = requests.get("http://127.0.0.1:8188/history", timeout=2) | |
| if resp.status_code == 200: | |
| server_ready = True | |
| print("ComfyUI server is ready!") | |
| break | |
| except: | |
| if i % 5 == 0: print(f"Waiting for server... ({i*2}s)") | |
| time.sleep(2) | |
| if not server_ready: | |
| print("Server logs (first 50 lines):") | |
| # In a real environment, we'd capture stdout/stderr, but for now we'll just fail clearly | |
| proc.terminate() | |
| raise RuntimeError("ComfyUI server failed to start. Port 8188 remained closed.") | |
| try: | |
| # 3. Load pre-converted API workflow | |
| workflow_path = os.path.join(ROOT_DIR, "simple_api_workflow.json") | |
| with open(workflow_path, 'r') as f: | |
| api_prompt = json.load(f) | |
| # Update node 11 (LoadImage) to point to our input.png | |
| if "11" in api_prompt: | |
| api_prompt["11"]["inputs"]["image"] = input_filename | |
| # Send to ComfyUI | |
| print(f"Queueing workflow to ComfyUI ({len(api_prompt)} nodes)...") | |
| prompt_data = {"prompt": api_prompt} | |
| resp = requests.post("http://127.0.0.1:8188/prompt", json=prompt_data) | |
| if resp.status_code != 200: | |
| raise RuntimeError(f"Failed to queue prompt: {resp.text}") | |
| prompt_id = resp.json().get("prompt_id") | |
| print(f"Prompt queued successfully (ID: {prompt_id})") | |
| # 4. Wait for completion | |
| # We poll the history endpoint until the prompt_id appears | |
| max_poll = 120 # 120 seconds for processing | |
| finished = False | |
| output_filename = None | |
| for p in range(max_poll): | |
| history_resp = requests.get(f"http://127.0.0.1:8188/history/{prompt_id}") | |
| if history_resp.status_code == 200: | |
| history = history_resp.json() | |
| if prompt_id in history: | |
| # Success! | |
| print("Processing complete!") | |
| # Extract output filename from the SaveImage node (ID 62) | |
| output_data = history[prompt_id]['outputs'].get('62') | |
| if output_data and 'images' in output_data: | |
| output_filename = output_data['images'][0]['filename'] | |
| finished = True | |
| break | |
| if p % 10 == 0: print(f"Still processing... ({p}s)") | |
| time.sleep(1) | |
| if not finished: | |
| raise RuntimeError("Processing timed out or failed to save image.") | |
| # 5. Return result | |
| output_path = os.path.join(output_dir, output_filename) | |
| return Image.open(output_path).copy() # Copy to avoid library closing issues | |
| finally: | |
| print("Shutting down ComfyUI server...") | |
| proc.terminate() | |
| try: | |
| proc.wait(timeout=5) | |
| except subprocess.TimeoutExpired: | |
| proc.kill() | |
| # Cleanup input file | |
| if os.path.exists(input_path): os.remove(input_path) | |
| # Premium UI with Fixed Height and No Share Buttons | |
| css = """ | |
| #container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| } | |
| .image-preview { | |
| max-height: 512px !important; | |
| } | |
| footer {display: none !important;} | |
| """ | |
| with gr.Blocks(title="SynthID Remover") as demo: | |
| with gr.Column(elem_id="container"): | |
| gr.Markdown("# SynthID Remover") | |
| gr.Markdown("This tool removes SynthID watermarks by re-rendering images through a high-fidelity diffusion reconstruction pipeline. It is specifically designed to bypass SynthID detection while maintaining the original image structure.") | |
| with gr.Row(): | |
| with gr.Column(): | |
| input_img = gr.Image(type="pil", label="Input Image", height=512) | |
| with gr.Column(): | |
| output_img = gr.Image(type="pil", label="Cleaned Image", height=512, interactive=False) | |
| submit_btn = gr.Button("Remove Watermark", variant="primary") | |
| submit_btn.click( | |
| fn=remove_watermark, | |
| inputs=[input_img], | |
| outputs=[output_img] | |
| ) | |
| with gr.Accordion("How it works & Acknowledgments", open=False): | |
| gr.Markdown(""" | |
| ### Acknowledgments | |
| This project is a direct implementation of the research by [00quebec/Synthid-Bypass](https://github.com/00quebec/Synthid-Bypass). All credit for the discovery and the original ComfyUI workflows goes to the original authors. | |
| ### Technical Breakdown | |
| The removal process works by re-processing the image through a specialized diffusion pipeline: | |
| 1. **Pixel Laundering**: The image is re-rendered using the **Z-Image-Turbo (S3-DiT)** model with a low denoising factor (0.2). This replaces the watermark's subtle noise patterns with new noise from the model. | |
| 2. **Structural Guidance**: To prevent the image from changing, a **Canny ControlNet** locks in the original geometry and composition. | |
| 3. **Multi-Pass Denoising**: The process runs in three iterative stages to gently scrub away the watermark without introducing artifacts. | |
| 4. **Face Restoration**: Using **FaceDetailer (YOLOv8)**, any detected faces are isolated and refined separately to preserve facial identity and high-end detail. | |
| """) | |
| if __name__ == "__main__": | |
| # In Gradio 6.0+, css moved to launch(), but title remains in Blocks() | |
| demo.launch(css=css) | |