192 lines
6.1 KiB
Python
192 lines
6.1 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 requests
|
|
from PIL import Image
|
|
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
|
from fastapi.responses import Response
|
|
|
|
# --- config -----------------------------------------------------------------
|
|
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")
|
|
|
|
|
|
# --- 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 _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
|
|
|
|
|
|
# --- routes -----------------------------------------------------------------
|
|
@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}")
|
|
|
|
area = max_area if max_area > 0 else MAX_AREA
|
|
w, h = _target_size(pil.width, pil.height, 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)
|
|
png = _comfy_fetch_image(outputs)
|
|
|
|
return Response(
|
|
content=png,
|
|
media_type="image/png",
|
|
headers={
|
|
"X-Seed": str(seed),
|
|
"X-Width": str(w),
|
|
"X-Height": str(h),
|
|
"X-Prompt-Id": prompt_id,
|
|
},
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"),
|
|
port=int(os.environ.get("PORT", "8500")))
|