""" edit_api.py — headless throughput API for Qwen-Image-Edit Rapid-AIO (v23 Q8 GGUF) running on top of a local ComfyUI server. Flow per request: image + prompt -> upload to ComfyUI -> inject into the workflow graph -> queue -> poll until done -> return the edited PNG. Run ComfyUI first (run_comfyui.sh), then this service (start_api.sh). """ import io import os import json import time import uuid import random import copy import threading import csv try: from . import database from . import embeddings from . import naming except ImportError: import database import embeddings import naming import requests from PIL import Image from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel import shutil import re # --- config ----------------------------------------------------------------- CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") WD_MODEL = os.environ.get("WD_MODEL", "SmilingWolf/wd-vit-tagger-v3") COMFY = os.environ.get("COMFY_URL", "http://127.0.0.1:8188").rstrip("/") WORKFLOW_PATH = os.environ.get( "WORKFLOW_PATH", os.path.join(os.path.dirname(os.path.abspath(__file__)), "workflow_qwen_edit.json"), ) # Default target pixel area for the output latent. The MI50 is not fast, so we # cap at ~1MP by default; raise via MAX_AREA env if you want bigger output. MAX_AREA = int(os.environ.get("MAX_AREA", str(1024 * 1024))) GEN_TIMEOUT = int(os.environ.get("GEN_TIMEOUT", "600")) # seconds per request # Node ids in workflow_qwen_edit.json (kept stable on purpose). NODE_LOADIMAGE = "4" NODE_POSITIVE = "5" NODE_NEGATIVE = "6" NODE_LATENT = "7" NODE_KSAMPLER = "8" NODE_SAVE = "10" MAX_SEED = 2**32 - 1 VIDEO_EXTENSIONS = ('.mp4', '.mov', '.avi', '.webm', '.mkv') # Poses where the source image should be rotated 180° before pipeline for better results ROTATE_180_POSES = {"the dragon", "dragon", "the draak", "draak"} # WD tagger tags that indicate the subject is wearing clothes CLOTHING_TAGS = { "dress", "skirt", "shirt", "top", "pants", "jeans", "jacket", "coat", "swimsuit", "bikini", "shorts", "uniform", "hoodie", "sweater", "blouse", "leggings", "stockings", "tights", "lingerie", "miniskirt", "pleated_skirt", "school_uniform", "maid_dress", "bodysuit", "sailor_uniform", "leotard", "corset", "kimono", "yukata", "cheongsam", "t-shirt", "tank_top", "crop_top", "tube_top", "halter_top", "negligee", "nightgown", "pajamas", "trench_coat", "vest", "bra", "underwear", "panties", "thong", "g-string", "bikini_top", "bikini_bottom", "one-piece_swimsuit", "sports_bra", "gym_clothes", } UNDRESS_PROMPT = "completely nude, bare skin, no clothing, naked body, natural skin texture" with open(WORKFLOW_PATH, "r", encoding="utf-8") as f: BASE_WORKFLOW = json.load(f) app = FastAPI(title="Qwen-Image-Edit Rapid-AIO API", version="1.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST", "DELETE"], allow_headers=["*"], ) def _sync_car_html(): src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "car.html") if not os.path.exists(src): return try: dest = os.path.join(_load_output_dir(), "car.html") shutil.copy2(src, dest) print(f"[car.html] synced → {dest}") except Exception as e: print(f"[car.html] sync warning: {e}") def _watch_car_html(): src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "car.html") last_mtime = os.path.getmtime(src) if os.path.exists(src) else 0 while True: time.sleep(1) try: mtime = os.path.getmtime(src) if mtime != last_mtime: last_mtime = mtime dest = os.path.join(_load_output_dir(), "car.html") shutil.copy2(src, dest) print(f"[car.html] change detected → synced to {dest}") except Exception: pass def _load_wireframe_dir() -> str: with open(CONFIG_PATH, "r") as f: conf = json.load(f) return conf.get("wireframe_dir", "/mnt/zim/tour-comfy/wireframe") def _load_faceswap_model_path() -> str: with open(CONFIG_PATH, "r") as f: conf = json.load(f) return os.path.expanduser(conf.get("faceswap_model", "~/.insightface/models/inswapper_128.onnx")) @app.on_event("startup") def on_startup(): try: database.migrate_schema() except Exception as e: print(f"DB migration warning: {e}") _sync_car_html() threading.Thread(target=_watch_car_html, daemon=True).start() # Mount wireframe static dir for browser video preview try: wf_dir = _load_wireframe_dir() if os.path.isdir(wf_dir): app.mount("/wireframe", StaticFiles(directory=wf_dir), name="wireframe") print(f"[wireframe] mounted {wf_dir} → /wireframe") except Exception as e: print(f"[wireframe] mount warning: {e}") # Mount output dir so images can be served via HTTP (/output/filename.png) try: out_dir = _load_output_dir() if os.path.isdir(out_dir): app.mount("/output", StaticFiles(directory=out_dir), name="output") print(f"[output] mounted {out_dir} → /output") except Exception as e: print(f"[output] mount warning: {e}") # Write initial static data files (synchronous — ensures files exist before first request) _write_all_static() # --- helpers ---------------------------------------------------------------- def _round16(x: int) -> int: return max(16, int(round(x / 16.0)) * 16) def _target_size(w: int, h: int, max_area: int) -> tuple[int, int]: """Scale (w, h) to ~max_area preserving aspect, rounded to /16.""" scale = (max_area / float(w * h)) ** 0.5 return _round16(w * scale), _round16(h * scale) def _prep_image(pil: Image.Image, max_area: int) -> tuple[Image.Image, int, int]: """ Prepare image for ComfyUI: 1. If area > max_area, crop from bottom if height remains >= 256. 2. Otherwise scale (up or down) to fit area while preserving aspect. 3. Ensure dimensions are rounded to 16. """ w, h = pil.width, pil.height if w * h > max_area: # Try to keep width and crop height from bottom rw = _round16(w) th = max_area // rw if th >= 256: rh = (th // 16) * 16 if rh < 16: rh = 16 # To avoid black bars from .crop((0,0,rw,rh)) when rw > w, # we crop to original w first, then resize to rw. pil = pil.crop((0, 0, w, min(h, (rh * w) // rw))) pil = pil.resize((rw, rh), resample=Image.LANCZOS) return pil, rw, rh else: # Too wide to keep width and have decent height, scale both down rw, rh = _target_size(w, h, max_area) pil = pil.resize((rw, rh), resample=Image.LANCZOS) return pil, rw, rh else: # Fits or is too small: scale UP to match the max_area budget # (Legacy behavior that gives better model performance) rw, rh = _target_size(w, h, max_area) if rw != w or rh != h: pil = pil.resize((rw, rh), resample=Image.LANCZOS) return pil, rw, rh def _comfy_upload(img_bytes: bytes, filename: str) -> str: """Upload an image to ComfyUI's input dir; return the stored name.""" r = requests.post( f"{COMFY}/upload/image", files={"image": (filename, img_bytes, "image/png")}, data={"overwrite": "true", "type": "input"}, timeout=60, ) r.raise_for_status() j = r.json() name = j["name"] sub = j.get("subfolder", "") return f"{sub}/{name}" if sub else name def _comfy_queue(graph: dict, client_id: str) -> str: r = requests.post( f"{COMFY}/prompt", json={"prompt": graph, "client_id": client_id}, timeout=60, ) if r.status_code != 200: raise HTTPException(502, f"ComfyUI rejected workflow: {r.text}") return r.json()["prompt_id"] def _comfy_wait(prompt_id: str, deadline: float) -> dict: """Poll /history until the prompt finishes; return its outputs dict.""" while time.time() < deadline: r = requests.get(f"{COMFY}/history/{prompt_id}", timeout=30) if r.status_code == 200: hist = r.json() if prompt_id in hist: entry = hist[prompt_id] status = entry.get("status", {}) if status.get("status_str") == "error": raise HTTPException(500, f"ComfyUI execution error: {json.dumps(status)}") outputs = entry.get("outputs", {}) if outputs: return outputs time.sleep(0.5) raise HTTPException(504, f"Generation timed out after {GEN_TIMEOUT}s") def _comfy_fetch_image(outputs: dict) -> bytes: node_out = outputs.get(NODE_SAVE) or next( (v for v in outputs.values() if "images" in v), None ) if not node_out or not node_out.get("images"): raise HTTPException(500, "No output image produced") img = node_out["images"][0] r = requests.get( f"{COMFY}/view", params={ "filename": img["filename"], "subfolder": img.get("subfolder", ""), "type": img.get("type", "output"), }, timeout=60, ) r.raise_for_status() return r.content # --- WD tagger (lazy) ------------------------------------------------------- _tagger = None # (model, transform, labels) once loaded _tagger_lock = threading.Lock() def _load_tagger(): global _tagger if _tagger is not None: return _tagger with _tagger_lock: if _tagger is not None: return _tagger import torch import timm from timm.data import create_transform, resolve_data_config import huggingface_hub model = timm.create_model(f"hf_hub:{WD_MODEL}", pretrained=True).eval() if torch.cuda.is_available(): model = model.cuda() cfg = resolve_data_config(model.pretrained_cfg, model=model) transform = create_transform(**cfg) lpath = huggingface_hub.hf_hub_download(WD_MODEL, "selected_tags.csv") with open(lpath, newline="") as f: rows = list(csv.DictReader(f)) # category 0=general 4=character 9=rating labels = [(r["name"], int(r.get("category", 9))) for r in rows] _tagger = (model, transform, labels) return _tagger def _run_tagger(pil_img: Image.Image, threshold: float = 0.35): import torch model, transform, labels = _load_tagger() tensor = transform(pil_img.convert("RGB")).unsqueeze(0) if torch.cuda.is_available(): tensor = tensor.cuda() with torch.no_grad(): scores = torch.sigmoid(model(tensor))[0].cpu().tolist() tags = [ {"tag": name, "score": round(score, 3), "cat": cat} for (name, cat), score in zip(labels, scores) if score >= threshold ] tags.sort(key=lambda x: -x["score"]) return tags def _tags_to_name(tags: list, max_tags: int = 8) -> str: content = [t["tag"] for t in tags if t["cat"] in (0, 4)][:max_tags] return " ".join(content).replace("_", " ") def _apply_transparency(png_bytes: bytes) -> bytes: """Use rembg to remove background and return PNG bytes with Alpha channel.""" try: from rembg import remove import io from PIL import Image img = Image.open(io.BytesIO(png_bytes)) # rembg works best on RGB if img.mode != "RGB": img = img.convert("RGB") out = remove(img) buf = io.BytesIO() out.save(buf, format="PNG") return buf.getvalue() except Exception as e: print(f"Error in transparency post-processing: {e}") return png_bytes # --- faceswapper (insightface + inswapper_128) -------------------------------- # Setup: pip install insightface onnxruntime-gpu opencv-python-headless # Download: place inswapper_128.onnx at ~/.insightface/models/inswapper_128.onnx # Source: https://huggingface.co/deepinsight/inswapper _faceswapper = None _faceswapper_lock = threading.Lock() # Dedicated single-worker pool for face-crop extraction. Running it here # instead of via FastAPI BackgroundTasks keeps the heavy insightface inference # (and its one-time model load) off the shared request threadpool, so quick # endpoints like /order stay responsive right after a "set preferred" click. # A single worker also serializes face jobs so a burst can't thrash the GPU. from concurrent.futures import ThreadPoolExecutor as _ThreadPoolExecutor _face_executor = _ThreadPoolExecutor(max_workers=1, thread_name_prefix="face") _gfpgan = None _gfpgan_lock = threading.Lock() def _load_faceswapper(): global _faceswapper if _faceswapper is not None: return _faceswapper with _faceswapper_lock: if _faceswapper is not None: return _faceswapper try: import insightface from insightface.app import FaceAnalysis except ImportError: raise RuntimeError("insightface not installed. Run: pip install insightface onnxruntime-gpu") providers = ['CUDAExecutionProvider', 'CPUExecutionProvider'] app = FaceAnalysis(name='buffalo_l', providers=providers) app.prepare(ctx_id=0, det_size=(640, 640)) model_path = _load_faceswap_model_path() if not os.path.exists(model_path): # Try HuggingFace download as fallback try: import huggingface_hub model_path = huggingface_hub.hf_hub_download( 'deepinsight/inswapper', 'inswapper_128.onnx', local_dir=os.path.dirname(model_path), ) print(f"[faceswap] Downloaded inswapper_128.onnx to {model_path}") except Exception as de: raise RuntimeError( f"inswapper_128.onnx not found at {model_path}. " f"Download from https://huggingface.co/deepinsight/inswapper and place it there. " f"Download error: {de}" ) swapper = insightface.model_zoo.get_model(model_path, providers=providers) _faceswapper = (app, swapper) print(f"[faceswap] loaded insightface buffalo_l + inswapper_128 from {model_path}") return _faceswapper def _load_gfpgan(): """Lazy-load GFPGAN face restorer. Returns restorer or False if unavailable.""" global _gfpgan if _gfpgan is not None: return _gfpgan with _gfpgan_lock: if _gfpgan is not None: return _gfpgan try: from gfpgan import GFPGANer # Main GFPGAN model model_path = os.path.expanduser('~/.gfpgan/weights/GFPGANv1.4.pth') os.makedirs(os.path.dirname(model_path), exist_ok=True) if not os.path.exists(model_path): import urllib.request print('[gfpgan] Downloading GFPGANv1.4.pth (~333 MB)...') tmp = model_path + '.tmp' urllib.request.urlretrieve( 'https://github.com/TencentARC/GFPGAN/releases/download/v1.3.4/GFPGANv1.4.pth', tmp ) os.rename(tmp, model_path) # GFPGANer hardcodes facexlib download path to CWD/gfpgan/weights/ # → change CWD to ~ so models land at ~/gfpgan/weights/ (stable across runs) home = os.path.expanduser('~') os.makedirs(os.path.join(home, 'gfpgan', 'weights'), exist_ok=True) _orig_cwd = os.getcwd() os.chdir(home) try: restorer = GFPGANer(model_path=model_path, upscale=1, arch='clean', channel_multiplier=2, bg_upsampler=None) finally: os.chdir(_orig_cwd) _gfpgan = restorer print('[gfpgan] GFPGANv1.4 loaded') except Exception as e: print(f'[gfpgan] not available: {e}') _gfpgan = False return _gfpgan def _make_video_poster(video_path: str) -> str | None: """Extract a poster JPG (sibling `.jpg`) so the gallery can show a thumbnail for a video via a plain (file:// can't render