Files
qwen-image/tour-comfy/edit_api.py
mike 1dead1c666 aa
2026-06-18 00:06:15 +02:00

320 lines
10 KiB
Python

"""
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 requests
from PIL import Image
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from pydantic import BaseModel
# --- config -----------------------------------------------------------------
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
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_LATENT = "7"
NODE_KSAMPLER = "8"
NODE_SAVE = "10"
MAX_SEED = 2**32 - 1
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"],
allow_headers=["*"],
)
# --- 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
# --- pipeline helper ---------------------------------------------------------
def _run_pipeline(
pil: Image.Image,
prompt: str,
seed: int = -1,
max_area: int = 0,
steps: int = 4,
cfg: float = 1.0,
sampler_name: str = "euler_ancestral",
scheduler: str = "beta",
) -> bytes:
area = max_area if max_area > 0 else MAX_AREA
pil, w, h = _prep_image(pil, area)
buf = io.BytesIO()
pil.save(buf, format="PNG")
stored = _comfy_upload(buf.getvalue(), f"in_{uuid.uuid4().hex[:8]}.png")
if seed is None or seed < 0:
seed = random.randint(0, MAX_SEED)
graph = copy.deepcopy(BASE_WORKFLOW)
graph[NODE_LOADIMAGE]["inputs"]["image"] = stored
graph[NODE_POSITIVE]["inputs"]["prompt"] = prompt
graph[NODE_LATENT]["inputs"]["width"] = w
graph[NODE_LATENT]["inputs"]["height"] = h
ks = graph[NODE_KSAMPLER]["inputs"]
ks.update(seed=seed, steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler)
client_id = uuid.uuid4().hex
prompt_id = _comfy_queue(graph, client_id)
outputs = _comfy_wait(prompt_id, time.time() + GEN_TIMEOUT)
return _comfy_fetch_image(outputs)
# --- batch state -------------------------------------------------------------
jobs: dict[str, dict] = {}
def _load_output_dir() -> str:
with open(CONFIG_PATH, "r") as f:
conf = json.load(f)
d = conf["output_dir"]
if not os.path.isabs(d):
d = os.path.normpath(os.path.join(os.path.dirname(CONFIG_PATH), "..", d))
return d
def _batch_worker(job_id: str, filenames: list, prompt: str, seed: int, max_area: int):
output_dir = _load_output_dir()
for fname in filenames:
fpath = os.path.join(output_dir, fname)
try:
pil = Image.open(fpath).convert("RGB")
png = _run_pipeline(pil, prompt, seed, max_area)
ts = time.strftime("%Y%m%d_%H%M%S")
out_name = f"{ts}_{fname}"
with open(os.path.join(output_dir, out_name), "wb") as f:
f.write(png)
jobs[job_id]["done"] += 1
except Exception as e:
jobs[job_id]["failed"] += 1
jobs[job_id]["status"] = "done"
# --- routes -----------------------------------------------------------------
class ConfigUpdate(BaseModel):
prompt: str | None = None
seed: int | None = None
@app.get("/config")
def get_config():
with open(CONFIG_PATH, "r") as f:
return json.load(f)
@app.post("/config")
def update_config(update: ConfigUpdate):
with open(CONFIG_PATH, "r") as f:
conf = json.load(f)
if update.prompt is not None:
conf["prompt"] = update.prompt
if update.seed is not None:
conf["seed"] = update.seed
with open(CONFIG_PATH, "w") as f:
json.dump(conf, f, indent=2)
return {"prompt": conf["prompt"], "seed": conf["seed"]}
class BatchRequest(BaseModel):
filenames: list[str]
prompt: str
seed: int = -1
max_area: int = 0
@app.post("/batch")
def start_batch(req: BatchRequest):
job_id = uuid.uuid4().hex[:8]
jobs[job_id] = {"status": "running", "total": len(req.filenames), "done": 0, "failed": 0}
t = threading.Thread(
target=_batch_worker,
args=(job_id, req.filenames, req.prompt, req.seed, req.max_area),
daemon=True,
)
t.start()
return {"job_id": job_id, "total": len(req.filenames)}
@app.get("/batch/{job_id}")
def get_batch(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Job not found")
return jobs[job_id]
@app.get("/health")
def health():
try:
requests.get(f"{COMFY}/system_stats", timeout=5).raise_for_status()
return {"status": "ok", "comfy": COMFY}
except Exception as e:
raise HTTPException(503, f"ComfyUI unreachable at {COMFY}: {e}")
@app.post("/edit")
async def edit(
image: UploadFile = File(...),
prompt: str = Form(...),
seed: int = Form(-1),
steps: int = Form(4),
cfg: float = Form(1.0),
sampler_name: str = Form("euler_ancestral"),
scheduler: str = Form("beta"),
max_area: int = Form(0),
):
raw = await image.read()
try:
pil = Image.open(io.BytesIO(raw)).convert("RGB")
except Exception as e:
raise HTTPException(400, f"Invalid image: {e}")
png = _run_pipeline(pil, prompt, seed, max_area, steps, cfg, sampler_name, scheduler)
return Response(content=png, media_type="image/png")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"),
port=int(os.environ.get("PORT", "8500")))