320 lines
10 KiB
Python
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")))
|