Files
qwen-image/tour-comfy/edit_api.py
mike 0176c1b965 aa
2026-06-20 02:43:57 +02:00

1174 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 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
# 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
@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()
# --- 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
# --- pipeline helper ---------------------------------------------------------
def _load_poses():
poses_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "poses.md")
if not os.path.exists(poses_path):
return {}
poses = {}
current_pose = None
current_beta = False
current_desc = []
with open(poses_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("# "):
if current_pose:
poses[current_pose] = {"text": " ".join(current_desc).strip(), "beta": current_beta}
raw = line[2:].rstrip(":").strip()
current_beta = bool(re.search(r'\(beta\)', raw, re.IGNORECASE))
current_pose = re.sub(r'\s*\(beta\)\s*', '', raw, flags=re.IGNORECASE).strip()
current_desc = []
elif line and current_pose:
current_desc.append(line)
if current_pose:
poses[current_pose] = {"text": " ".join(current_desc).strip(), "beta": current_beta}
return poses
def _detect_has_background(pil: Image.Image) -> bool:
"""Return False when the image has significant alpha transparency (background removed)."""
if pil.mode != 'RGBA':
return True
alpha = pil.split()[3]
hist = alpha.histogram()
transparent_px = sum(hist[:128])
return transparent_px / (pil.width * pil.height) < 0.1
def _detect_has_clothing(tags: list) -> bool | None:
"""Return True if any tag from CLOTHING_TAGS appears above threshold, None if no tags."""
if not tags:
return None
tag_names = {t["tag"] for t in tags}
return bool(tag_names & CLOTHING_TAGS)
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",
extra_images: list = None, # additional PIL images wired to image2, image3
) -> 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
# Inject extra reference images as image2 / image3 on the positive encoder
if extra_images:
for i, extra_pil in enumerate(extra_images[:2]):
extra_buf = io.BytesIO()
extra_pil.convert("RGB").save(extra_buf, format="PNG")
extra_stored = _comfy_upload(extra_buf.getvalue(), f"in_{uuid.uuid4().hex[:8]}.png")
node_id = str(11 + i) # "11" → image2, "12" → image3
img_key = f"image{i + 2}"
graph[node_id] = {
"class_type": "LoadImage",
"inputs": {"image": extra_stored},
"_meta": {"title": f"ref image {i + 2}"},
}
graph[NODE_POSITIVE]["inputs"][img_key] = [node_id, 0]
# Transparency detection
is_transparent = any(kw in prompt.lower() for kw in ["transparent", "no background", "remove background", "alpha channel"])
if is_transparent:
graph[NODE_NEGATIVE]["inputs"]["prompt"] = "checkerboard, grid, pattern, texture, background details, watermark, deformed anatomy"
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_bytes = _comfy_fetch_image(outputs)
if is_transparent:
png_bytes = _apply_transparency(png_bytes)
return png_bytes
# --- 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 _move_to_trash(filepath: str):
if not filepath or not os.path.exists(filepath):
return
output_dir = _load_output_dir()
trash_dir = os.path.join(output_dir, ".trash")
os.makedirs(trash_dir, exist_ok=True)
filename = os.path.basename(filepath)
ts = time.strftime("%Y%m%d_%H%M%S")
trash_path = os.path.join(trash_dir, f"{ts}_{filename}")
try:
shutil.move(filepath, trash_path)
except Exception as e:
print(f"Error moving {filepath} to trash: {e}")
def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
seed: int, max_area: int, group_id: str | None = None):
output_dir = _load_output_dir()
for fname in filenames:
actual_gid = None
try:
person = database.get_person(fname)
# Prefer the source's existing DB group_id; fall back to the caller-supplied
# group_id (which is the gallery gid, potentially stale) or the basename.
if person and person[1]:
actual_gid = person[1]
else:
actual_gid = group_id or naming.get_base_name(fname)
database.upsert_person(fname, group_id=actual_gid)
except Exception as e:
print(f"Error determining/updating group for {fname}: {e}")
actual_gid = group_id or naming.get_base_name(fname)
fpath = os.path.join(output_dir, fname)
if not os.path.exists(fpath):
jobs[job_id]["failed"] += len(prompts)
continue
try:
base_pil = Image.open(fpath).convert("RGB")
for prompt, pose in zip(prompts, poses):
try:
pil = base_pil
# Rotate 180° for poses that work better upside-down
if pose and pose.lower().strip() in ROTATE_180_POSES:
pil = pil.rotate(180)
png = _run_pipeline(pil, prompt, seed, max_area)
ts = time.strftime("%Y%m%d_%H%M%S")
clean_fname = naming.get_base_name(fname)
out_name = f"{ts}_{clean_fname}"
out_path = os.path.join(output_dir, out_name)
with open(out_path, "wb") as f:
f.write(png)
has_bg = True
try:
out_pil = Image.open(io.BytesIO(png))
has_bg = _detect_has_background(out_pil)
except Exception:
pass
try:
embedding = embeddings.generate_embedding(out_path)
next_order = database.get_next_sort_order(actual_gid)
database.upsert_person(
out_name, filepath=out_path, embedding=embedding,
group_id=actual_gid, prompt=prompt, pose=pose,
has_background=has_bg, sort_order=next_order,
source_refs=json.dumps([fname]),
)
except Exception as db_err:
print(f"Database error in batch worker: {db_err}")
jobs[job_id]["done"] += 1
except Exception as e:
print(f"Error in batch for {fname} with prompt '{prompt}': {e}")
jobs[job_id]["failed"] += 1
except Exception as e:
print(f"Error opening {fname}: {e}")
jobs[job_id]["failed"] += len(prompts)
jobs[job_id]["status"] = "done"
def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], poses: list,
seed: int, max_area: int):
"""Generate one output image per prompt using filenames[0] as primary and the rest as extra refs."""
output_dir = _load_output_dir()
pils = []
for fname in filenames:
fpath = os.path.join(output_dir, fname)
if os.path.exists(fpath):
pils.append((fname, Image.open(fpath).convert("RGB")))
if not pils:
jobs[job_id]["status"] = "done"
return
# Output group: reuse shared group if all sources belong to the same one, else new group
source_groups = set()
for fname, _ in pils:
try:
p = database.get_person(fname)
if p and p[1]:
source_groups.add(p[1])
except Exception:
pass
if len(source_groups) == 1:
output_gid = next(iter(source_groups))
else:
output_gid = f"cg_{uuid.uuid4().hex[:8]}"
primary_fname, primary_pil = pils[0]
extra_pils = [p for _, p in pils[1:]]
for prompt, pose in zip(prompts, poses):
try:
work_pil = primary_pil
if pose and pose.lower().strip() in ROTATE_180_POSES:
work_pil = work_pil.rotate(180)
png = _run_pipeline(work_pil, prompt, seed, max_area, extra_images=extra_pils)
ts = time.strftime("%Y%m%d_%H%M%S")
clean = naming.get_base_name(primary_fname)
out_name = f"{ts}_mr_{clean}"
out_path = os.path.join(output_dir, out_name)
with open(out_path, "wb") as f:
f.write(png)
has_bg = True
try:
out_pil = Image.open(io.BytesIO(png))
has_bg = _detect_has_background(out_pil)
except Exception:
pass
try:
embedding = embeddings.generate_embedding(out_path)
next_order = database.get_next_sort_order(output_gid)
database.upsert_person(out_name, filepath=out_path, embedding=embedding,
group_id=output_gid, prompt=prompt, pose=pose,
has_background=has_bg, sort_order=next_order,
source_refs=json.dumps([f for f, _ in pils]))
except Exception as db_err:
print(f"DB error in multi-ref: {db_err}")
jobs[job_id]["done"] += 1
except Exception as e:
print(f"Error in multi-ref for prompt '{prompt}': {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 | list[str]
seed: int = -1
max_area: int = 0
group_id: str | None = None
poses: list[str | None] | None = None # pose name per prompt (same index), or None; None entries = no pose
@app.post("/batch")
def start_batch(req: BatchRequest):
prompts = [req.prompt] if isinstance(req.prompt, str) else req.prompt
poses = req.poses or [None] * len(prompts)
# Pad poses list to match prompts length
while len(poses) < len(prompts):
poses.append(None)
total_tasks = len(req.filenames) * len(prompts)
job_id = uuid.uuid4().hex[:8]
jobs[job_id] = {"status": "running", "total": total_tasks, "done": 0, "failed": 0}
t = threading.Thread(
target=_batch_worker,
args=(job_id, req.filenames, prompts, poses, req.seed, req.max_area, req.group_id),
daemon=True,
)
t.start()
return {"job_id": job_id, "total": total_tasks}
class MultiRefRequest(BaseModel):
filenames: list[str] # 23 reference images; first is primary (image1)
prompt: str | list[str]
poses: list[str | None] | None = None
seed: int = -1
max_area: int = 0
@app.post("/multi-ref")
def start_multi_ref(req: MultiRefRequest):
if len(req.filenames) < 2:
raise HTTPException(400, "multi-ref requires at least 2 filenames")
filenames = req.filenames[:3] # cap at 3 (image1/2/3)
prompts = [req.prompt] if isinstance(req.prompt, str) else req.prompt
poses = req.poses or [None] * len(prompts)
while len(poses) < len(prompts):
poses.append(None)
job_id = uuid.uuid4().hex[:8]
jobs[job_id] = {"status": "running", "total": len(prompts), "done": 0, "failed": 0}
t = threading.Thread(
target=_multi_ref_worker,
args=(job_id, filenames, prompts, poses, req.seed, req.max_area),
daemon=True,
)
t.start()
return {"job_id": job_id, "total": len(prompts)}
@app.get("/poses")
def get_poses():
return _load_poses()
@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("/images")
def list_images():
output_dir = _load_output_dir()
extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg')
try:
# Try to get from DB first
try:
persons = database.list_persons()
# persons: (filename, name, group_id, clip_description, prompt, pose, sort_order, group_name, hidden, has_background, source_refs, has_clothing)
db_images = []
for p in persons:
fpath = os.path.join(output_dir, p[0])
if not os.path.exists(fpath):
continue # skip orphan DB records whose file no longer exists
db_images.append({
"filename": p[0],
"name": p[1],
"group_id": p[2],
"clip_description":p[3],
"prompt": p[4],
"pose": p[5],
"sort_order": p[6],
"group_name": p[7],
"hidden": bool(p[8]) if p[8] else False,
"has_background": bool(p[9]) if p[9] is not None else True,
"source_refs": p[10],
"has_clothing": p[11],
})
db_images.sort(
key=lambda x: os.path.getmtime(os.path.join(output_dir, x["filename"])),
reverse=True,
)
return {"images": db_images}
except Exception as db_err:
print(f"DB error in list_images: {db_err}")
# Fallback to filesystem
files = [f for f in os.listdir(output_dir) if f.lower().endswith(extensions)]
files.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
return {"images": [{"filename": f} for f in files]}
except Exception as e:
raise HTTPException(500, str(e))
# --- tagging routes ----------------------------------------------------------
class TagRequest(BaseModel):
filename: str
threshold: float = 0.35
max_tags: int = 8
group_id: str | None = None
@app.post("/tag")
def tag_image(req: TagRequest):
output_dir = _load_output_dir()
fpath = os.path.join(output_dir, req.filename)
if not os.path.exists(fpath):
raise HTTPException(404, "File not found in output dir")
try:
pil = Image.open(fpath)
tags = _run_tagger(pil, req.threshold)
clip_desc = _tags_to_name(tags, req.max_tags)
has_clothing = _detect_has_clothing(tags)
# Only assign a new name if the image doesn't already have one
existing = database.get_person(req.filename)
auto_name = (existing[0] if existing and existing[0] else None) or naming.generate_associative_name(tags)
# Save to DB
try:
embedding = embeddings.generate_embedding(fpath)
database.upsert_person(
req.filename, filepath=fpath, name=auto_name,
clip_description=clip_desc, tags=tags, embedding=embedding,
group_id=req.group_id, has_clothing=has_clothing,
)
except Exception as db_err:
print(f"Database error during tag: {db_err}")
return {"filename": req.filename, "clip_description": clip_desc, "tags": tags[:30], "has_clothing": has_clothing}
except Exception as e:
raise HTTPException(500, str(e))
@app.get("/names")
def get_names():
try:
persons = database.list_persons()
return {p[0]: p[1] for p in persons if p[1]}
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/names/{filename}")
def set_name(filename: str, body: dict):
name = body.get("name", "")
try:
database.upsert_person(filename, name=name)
except Exception as db_err:
print(f"Database error in set_name: {db_err}")
return {"filename": filename, "name": name}
# --- group routes ------------------------------------------------------------
@app.get("/groups")
def get_groups():
try:
persons = database.list_persons()
return {p[0]: p[2] for p in persons if p[2]}
except Exception as e:
raise HTTPException(500, str(e))
class MergeRequest(BaseModel):
filenames: list[str]
group_id: str | None = None
@app.post("/groups/merge")
def merge_groups(req: MergeRequest):
gid = req.group_id or f"cg_{uuid.uuid4().hex[:8]}"
for fname in req.filenames:
try:
database.upsert_person(fname, group_id=gid)
except Exception as db_err:
print(f"Database error in merge: {db_err}")
return {"group_id": gid, "files": req.filenames}
class ExtractRequest(BaseModel):
filename: str
@app.post("/groups/extract")
def extract_from_group(req: ExtractRequest):
gid = f"solo:{req.filename}"
try:
database.upsert_person(req.filename, group_id=gid)
except Exception as db_err:
print(f"Database error in extract: {db_err}")
return {"filename": req.filename}
@app.get("/group-names")
def get_group_names():
try:
return database.get_all_group_names()
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/group-names/{group_id}")
def set_group_name(group_id: str, body: dict):
name = body.get("name", "").strip()
try:
database.set_group_name(group_id, name or None)
except Exception as e:
raise HTTPException(500, str(e))
return {"group_id": group_id, "name": name}
@app.get("/groups/{group_id}/order")
def get_group_order(group_id: str):
try:
rows = database.get_group_order(group_id)
return {"group_id": group_id, "filenames": [r[0] for r in rows]}
except Exception as e:
raise HTTPException(500, str(e))
class GroupOrderRequest(BaseModel):
filenames: list[str]
@app.post("/groups/{group_id}/order")
def set_group_order(group_id: str, req: GroupOrderRequest):
try:
database.set_group_order(group_id, req.filenames)
except Exception as e:
raise HTTPException(500, str(e))
return {"group_id": group_id, "filenames": req.filenames}
@app.get("/similar/{filename}")
def get_similar(filename: str, limit: int = 10):
person = database.get_person(filename)
if not person or person[3] is None:
raise HTTPException(404, "Image or embedding not found")
embedding = person[3]
results = database.search_similar(embedding, limit=limit)
similar = []
for r in results:
# Avoid returning the same image as the most similar
if r[0] == filename:
continue
similar.append({
"filename": r[0],
"name": r[1],
"group_id": r[2],
"clip_description": r[3],
"distance": float(r[4])
})
return {"filename": filename, "similar": similar}
@app.post("/db/cleanup")
def db_cleanup():
"""Delete DB records for files that no longer exist on disk."""
output_dir = _load_output_dir()
persons = database.list_persons()
removed = []
for p in persons:
fpath = os.path.join(output_dir, p[0])
if not os.path.exists(fpath):
database.delete_person(p[0])
removed.append(p[0])
return {"removed": len(removed), "filenames": removed}
@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}")
def _crop_to_bbox(pil_img: Image.Image, margin: int = 20, top_margin: int = 20, headroom: float = 0.05) -> Image.Image:
if pil_img.mode != 'RGBA':
return pil_img
alpha = pil_img.split()[-1]
bbox = alpha.getbbox()
if not bbox:
return pil_img
left, upper, right, lower = bbox
left = max(0, left - margin)
upper = max(0, upper - top_margin)
right = min(pil_img.width, right + margin)
lower = min(pil_img.height, lower + margin)
cropped = pil_img.crop((left, upper, right, lower))
if headroom > 0:
h_px = int(cropped.height * headroom)
if h_px > 0:
new_img = Image.new("RGBA", (cropped.width, cropped.height + h_px), (0, 0, 0, 0))
new_img.paste(cropped, (0, h_px))
return new_img
return cropped
def _process_upload(file_path: str, filename: str, prompts: list[str], name: str | None = None, group_id: str | None = None):
output_dir = _load_output_dir()
try:
pil = Image.open(file_path)
# 1. CLIP tag the source
tags = _run_tagger(pil.convert("RGB"))
clip_desc = _tags_to_name(tags)
has_clothing = _detect_has_clothing(tags)
auto_name = name or naming.generate_associative_name(tags)
# 2. Embedding for source
embedding = embeddings.generate_embedding(file_path)
# 3. Register source in DB — sort_order=0 makes it the preferred base image
database.upsert_person(
filename, filepath=file_path, name=auto_name,
clip_description=clip_desc, tags=tags, embedding=embedding,
group_id=group_id, sort_order=0, has_clothing=has_clothing,
)
# 4. Crop if needed
cropped_pil = _crop_to_bbox(pil)
# 5. Run prompts
for i, prompt in enumerate(prompts):
try:
png = _run_pipeline(cropped_pil.convert("RGB"), prompt)
ts = time.strftime("%Y%m%d_%H%M%S")
out_name = f"{ts}_{i}_{filename}"
if not out_name.lower().endswith(".png"):
out_name += ".png"
out_path = os.path.join(output_dir, out_name)
with open(out_path, "wb") as f:
f.write(png)
out_embedding = embeddings.generate_embedding(out_path)
next_order = database.get_next_sort_order(group_id)
database.upsert_person(
out_name, filepath=out_path, name=auto_name,
clip_description=clip_desc, embedding=out_embedding,
group_id=group_id, sort_order=next_order,
)
except Exception as e:
print(f"Error processing prompt '{prompt}' for {filename}: {e}")
except Exception as e:
print(f"Error in _process_upload for {filename}: {e}")
@app.post("/upload")
def upload_image(
background_tasks: BackgroundTasks,
image: UploadFile = File(...),
prompts: str = Form(""),
name: str = Form(None),
):
# Load config to get output_dir (we use output_dir for UI uploads to avoid watcher conflict)
with open(CONFIG_PATH, "r") as f:
conf = json.load(f)
output_dir = _load_output_dir()
os.makedirs(output_dir, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
safe_filename = re.sub(r'[^a-zA-Z0-9_.-]', '_', image.filename)
# Ensure extension
if not safe_filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
safe_filename += ".png"
filename = f"{ts}_{safe_filename}"
file_path = os.path.join(output_dir, filename)
with open(file_path, "wb") as f:
shutil.copyfileobj(image.file, f)
prompt_list = [p.strip() for p in prompts.split(",") if p.strip()]
# Add base-set prompts if defined in config
base_prompts = conf.get("base_prompts", [])
if isinstance(base_prompts, list):
prompt_list.extend(base_prompts)
if not prompt_list:
# Use default prompt from config
prompt_list = [conf.get("prompt", "high quality, masterpiece")]
group_id = f"up_{uuid.uuid4().hex[:8]}" # unique per upload; avoids collisions when pasting generic filenames
background_tasks.add_task(_process_upload, file_path, filename, prompt_list, name, group_id)
return {"status": "processing", "filename": filename, "group_id": group_id, "prompts": prompt_list}
@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")
@app.post("/images/{filename}/hidden")
def set_image_hidden(filename: str, body: dict):
hidden = bool(body.get("hidden", False))
try:
database.set_hidden(filename, hidden)
except Exception as e:
raise HTTPException(500, str(e))
return {"filename": filename, "hidden": hidden}
@app.post("/images/{filename}/set-preferred")
def set_image_preferred(filename: str):
"""Make this image sort_order=0 within its group, shifting others to 1,2,..."""
person = database.get_person(filename)
if not person:
raise HTTPException(404, "Image not found")
group_id = person[1]
if not group_id:
raise HTTPException(400, "Image has no group assigned")
rows = database.get_group_order(group_id)
others = [r[0] for r in rows if r[0] != filename]
database.set_group_order(group_id, [filename] + others)
return {"filename": filename, "group_id": group_id}
@app.post("/images/{filename}/undress")
def undress_image(filename: str, background_tasks: BackgroundTasks):
"""Queue a generation using the undress prompt on the given image."""
output_dir = _load_output_dir()
fpath = os.path.join(output_dir, filename)
if not os.path.exists(fpath):
raise HTTPException(404, "Image not found")
person = database.get_person(filename)
group_id = person[1] if person and person[1] else naming.get_base_name(filename)
job_id = uuid.uuid4().hex[:8]
jobs[job_id] = {"status": "queued", "done": 0, "failed": 0, "total": 1}
threading.Thread(
target=_batch_worker,
args=(job_id, [filename], [UNDRESS_PROMPT], [None],
random.randint(0, MAX_SEED), MAX_AREA),
kwargs={"group_id": group_id},
daemon=True,
).start()
return {"job_id": job_id, "filename": filename}
@app.delete("/images/{filename}")
def delete_image(filename: str):
person = database.get_person(filename)
if person and person[5] and os.path.exists(person[5]):
_move_to_trash(person[5])
database.delete_person(filename)
return {"status": "deleted", "filename": filename}
@app.delete("/groups/{group_id}")
def delete_group(group_id: str):
files = database.get_group_files(group_id)
for filename, filepath in files:
if filepath and os.path.exists(filepath):
_move_to_trash(filepath)
database.delete_group(group_id)
return {"status": "deleted", "group_id": group_id}
@app.post("/remove-background/{filename}")
def remove_background(filename: str):
person = database.get_person(filename)
if not person or not person[5] or not os.path.exists(person[5]):
raise HTTPException(404, "Image file not found")
path = person[5]
with open(path, "rb") as f:
png_bytes = f.read()
transparent_png = _apply_transparency(png_bytes)
with open(path, "wb") as f:
f.write(transparent_png)
return {"status": "success", "filename": filename}
@app.post("/remove-background/group/{group_id}")
def remove_background_group(group_id: str, background_tasks: BackgroundTasks):
def _bg_task():
files = database.get_group_files(group_id)
for filename, filepath in files:
if filepath and os.path.exists(filepath):
try:
with open(filepath, "rb") as f:
png_bytes = f.read()
transparent_png = _apply_transparency(png_bytes)
with open(filepath, "wb") as f:
f.write(transparent_png)
except Exception as e:
print(f"Error removing background for {filename}: {e}")
background_tasks.add_task(_bg_task)
return {"status": "processing", "group_id": group_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")))