aa
This commit is contained in:
72
tour-comfy/README.md
Normal file
72
tour-comfy/README.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Qwen-Image-Edit Rapid-AIO v23 — headless edit API (tour / MI50)
|
||||||
|
|
||||||
|
Runs Phr00t's **Rapid-AIO NSFW v23** (the same tune as the gradio app) as a
|
||||||
|
**Q8 GGUF** on the AMD Instinct **MI50 32GB** (`tour`, gfx906, ROCm 5.7,
|
||||||
|
torch 2.3.1), served by ComfyUI behind a thin FastAPI throughput endpoint:
|
||||||
|
**image + prompt in → edited PNG out**.
|
||||||
|
|
||||||
|
## Layout on `tour`
|
||||||
|
```
|
||||||
|
/media/tour/APPS/comfyui/
|
||||||
|
venv/ torch 2.3.1+rocm5.7 + ComfyUI deps
|
||||||
|
ComfyUI/ backend + ComfyUI-GGUF custom node
|
||||||
|
models/unet/Qwen-Rapid-NSFW-v23_Q8_0.gguf (21.8 GB)
|
||||||
|
models/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors (9.4 GB)
|
||||||
|
models/vae/qwen_image_vae.safetensors (0.25 GB)
|
||||||
|
api/ edit_api.py + workflow + scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
```bash
|
||||||
|
# 1) backend (terminal 1)
|
||||||
|
/media/tour/APPS/comfyui/api/run_comfyui.sh
|
||||||
|
# 2) API (terminal 2)
|
||||||
|
/media/tour/APPS/comfyui/api/start_api.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://192.168.1.160:8500/edit \
|
||||||
|
-F image=@car.png \
|
||||||
|
-F 'prompt=put the car on a beach at sunset' \
|
||||||
|
-F seed=-1 -F steps=4 \
|
||||||
|
-o out.png
|
||||||
|
```
|
||||||
|
Form fields: `prompt` (required), `seed` (-1 = random), `steps` (4),
|
||||||
|
`cfg` (1.0), `sampler_name` (euler_ancestral), `scheduler` (beta),
|
||||||
|
`max_area` (px, 0 = server default ~1MP). Response headers expose
|
||||||
|
`X-Seed`, `X-Width`, `X-Height`.
|
||||||
|
|
||||||
|
Settings match Phr00t's v23 recommendation: **euler_ancestral / beta, 4 steps,
|
||||||
|
CFG 1**. Output size tracks the input aspect ratio, scaled to `MAX_AREA`.
|
||||||
|
|
||||||
|
## Performance (MI50, gfx906, 4 steps)
|
||||||
|
Compute-bound — the 20.8GB unet stays resident; only the text encoder swaps.
|
||||||
|
| Output budget | Latency |
|
||||||
|
|---------------|---------|
|
||||||
|
| 0.59 MP | ~110 s |
|
||||||
|
| 0.79 MP (default) | ~140 s |
|
||||||
|
| 1.0 MP | ~180 s |
|
||||||
|
|
||||||
|
## Gotchas (why it's built this way)
|
||||||
|
- **ComfyUI pinned to `v0.3.77`.** `master` imports `comfy_kitchen`, which needs
|
||||||
|
`torch.library.custom_op` (torch ≥ 2.4). gfx906/ROCm-5.7 tops out at the proven
|
||||||
|
`torch 2.3.1+rocm5.7`, so newer ComfyUI won't import. v0.3.77 is the newest tag
|
||||||
|
that still has `TextEncodeQwenImageEditPlus` without that dependency.
|
||||||
|
- **`--use-split-cross-attention`** (in `run_comfyui.sh`) is required: the default
|
||||||
|
pytorch attention path OOMs a 20B edit model on 32GB. Split attention chunks the
|
||||||
|
attention matmul so it fits.
|
||||||
|
- The audio custom-node import errors in the ComfyUI log (`libcudart.so.13`) are
|
||||||
|
harmless — a CUDA `torchaudio` got pulled in; image editing doesn't use it.
|
||||||
|
|
||||||
|
## Manage (systemd)
|
||||||
|
```bash
|
||||||
|
sudo bash /media/tour/APPS/comfyui/api/deploy.sh # (re)install and start services
|
||||||
|
sudo bash /media/tour/APPS/comfyui/api/stop.sh # stop and disable services
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
```bash
|
||||||
|
journalctl -u comfyui-backend -f
|
||||||
|
journalctl -u comfyui-api -f
|
||||||
|
```
|
||||||
30
tour-comfy/deploy.sh
Executable file
30
tour-comfy/deploy.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy systemd services for Qwen-Image-Edit
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Must be run with sudo to copy to /etc/systemd/system
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "This script must be run as root (use sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
SYSTEMD_DIR="$SCRIPT_DIR/systemd"
|
||||||
|
|
||||||
|
echo "Copying service files..."
|
||||||
|
cp "$SYSTEMD_DIR/comfyui-backend.service" /etc/systemd/system/
|
||||||
|
cp "$SYSTEMD_DIR/comfyui-api.service" /etc/systemd/system/
|
||||||
|
|
||||||
|
echo "Reloading systemd daemon..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
echo "Enabling services..."
|
||||||
|
systemctl enable comfyui-backend.service
|
||||||
|
systemctl enable comfyui-api.service
|
||||||
|
|
||||||
|
echo "Starting services..."
|
||||||
|
systemctl restart comfyui-backend.service
|
||||||
|
systemctl restart comfyui-api.service
|
||||||
|
|
||||||
|
echo "Deployment complete."
|
||||||
|
echo "Check status with: systemctl status comfyui-backend comfyui-api"
|
||||||
191
tour-comfy/edit_api.py
Normal file
191
tour-comfy/edit_api.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""
|
||||||
|
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")))
|
||||||
19
tour-comfy/run_comfyui.sh
Normal file
19
tour-comfy/run_comfyui.sh
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Launch the ComfyUI backend (headless) for the Qwen-Image-Edit API.
|
||||||
|
# gfx906 (MI50) has no flash-attention, so use the pytorch cross-attention path.
|
||||||
|
set -e
|
||||||
|
BASE=/media/tour/APPS/comfyui
|
||||||
|
cd "$BASE/ComfyUI"
|
||||||
|
source "$BASE/venv/bin/activate"
|
||||||
|
|
||||||
|
# MI50 / Vega20 is happiest in fp16; avoid bf16 emulation.
|
||||||
|
export PYTORCH_HIP_ALLOC_CONF=expandable_segments:True
|
||||||
|
export HSA_ENABLE_SDMA=0
|
||||||
|
|
||||||
|
# Split cross-attention chunks the attention matmul -> much lower peak VRAM,
|
||||||
|
# which is what lets the 20B Q8 edit model + reference-image sequence fit in 32GB.
|
||||||
|
exec python main.py \
|
||||||
|
--listen 127.0.0.1 \
|
||||||
|
--port 8188 \
|
||||||
|
--use-split-cross-attention \
|
||||||
|
"$@"
|
||||||
16
tour-comfy/start_api.sh
Normal file
16
tour-comfy/start_api.sh
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Launch the FastAPI edit service (talks to the local ComfyUI on :8188).
|
||||||
|
set -e
|
||||||
|
BASE=/media/tour/APPS/comfyui
|
||||||
|
source "$BASE/venv/bin/activate"
|
||||||
|
cd "$BASE/api"
|
||||||
|
|
||||||
|
export COMFY_URL="http://127.0.0.1:8188"
|
||||||
|
export HOST="0.0.0.0"
|
||||||
|
export PORT="8500"
|
||||||
|
# Output pixel budget. MI50 is compute-bound on this 20B model:
|
||||||
|
# ~0.59MP -> ~110s ~0.79MP -> ~140s ~1.0MP -> ~180s (4 steps)
|
||||||
|
# 0.79MP is a sane speed/quality default; raise for bigger output.
|
||||||
|
export MAX_AREA="${MAX_AREA:-786432}"
|
||||||
|
|
||||||
|
exec python edit_api.py
|
||||||
18
tour-comfy/stop.sh
Executable file
18
tour-comfy/stop.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stop and disable systemd services for Qwen-Image-Edit
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "This script must be run as root (use sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping services..."
|
||||||
|
systemctl stop comfyui-api.service
|
||||||
|
systemctl stop comfyui-backend.service
|
||||||
|
|
||||||
|
echo "Disabling services..."
|
||||||
|
systemctl disable comfyui-api.service
|
||||||
|
systemctl disable comfyui-backend.service
|
||||||
|
|
||||||
|
echo "Services stopped and disabled."
|
||||||
17
tour-comfy/systemd/comfyui-api.service
Normal file
17
tour-comfy/systemd/comfyui-api.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Qwen-Image-Edit FastAPI Service
|
||||||
|
After=comfyui-backend.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=tour
|
||||||
|
Group=tour
|
||||||
|
WorkingDirectory=/media/tour/APPS/comfyui/api
|
||||||
|
ExecStart=/bin/bash /media/tour/APPS/comfyui/api/start_api.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
17
tour-comfy/systemd/comfyui-backend.service
Normal file
17
tour-comfy/systemd/comfyui-backend.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ComfyUI Backend for Qwen-Image-Edit
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=tour
|
||||||
|
Group=tour
|
||||||
|
WorkingDirectory=/media/tour/APPS/comfyui
|
||||||
|
ExecStart=/bin/bash /media/tour/APPS/comfyui/api/run_comfyui.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user