Files
qwen-image/tour-comfy/edit_api.py
mike 045b9b6458 aa
2026-06-18 23:26:30 +02:00

877 lines
29 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 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")
NAMES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "names.json")
GROUPS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "groups.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
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=["*"],
)
# --- 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 _load_json(path: str) -> dict:
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return {}
def _save_json(path: str, data: dict):
with open(path, "w") as f:
json.dump(data, f, indent=2)
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_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] = " ".join(current_desc).strip()
current_pose = line[2:].rstrip(":").strip()
current_desc = []
elif line and current_pose:
current_desc.append(line)
if current_pose:
poses[current_pose] = " ".join(current_desc).strip()
return poses
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
# 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"
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], seed: int, max_area: int, group_id: str | None = None):
output_dir = _load_output_dir()
for fname in filenames:
# If no group_id provided, try to inherit from source
actual_gid = group_id
if not actual_gid:
try:
person = database.get_person(fname)
if person and person[1]: # group_id is at index 1
actual_gid = person[1]
else:
# Create a new group for this standalone image
actual_gid = naming.get_base_name(fname)
# Update source image to join this group
database.upsert_person(fname, group_id=actual_gid)
except Exception as e:
print(f"Error determining group for {fname}: {e}")
fpath = os.path.join(output_dir, fname)
if not os.path.exists(fpath):
jobs[job_id]["failed"] += len(prompts)
continue
try:
pil = Image.open(fpath).convert("RGB")
for prompt in prompts:
try:
png = _run_pipeline(pil, prompt, seed, max_area)
ts = time.strftime("%Y%m%d_%H%M%S")
# Clean filename to avoid nested timestamps
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)
# Register in DB
try:
embedding = embeddings.generate_embedding(out_path)
database.upsert_person(out_name, filepath=out_path, embedding=embedding, group_id=actual_gid)
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"
# --- 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
@app.post("/batch")
def start_batch(req: BatchRequest):
prompts = [req.prompt] if isinstance(req.prompt, str) else req.prompt
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, req.seed, req.max_area, req.group_id),
daemon=True,
)
t.start()
return {"job_id": job_id, "total": total_tasks}
@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 is (filename, name, group_id, clip_description)
db_images = []
for p in persons:
db_images.append({
"filename": p[0],
"name": p[1],
"group_id": p[2],
"clip_description": p[3]
})
# Still sort by mtime for consistency with filesystem
db_images.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x["filename"])) if os.path.exists(os.path.join(output_dir, x["filename"])) else 0, 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)
auto_name = 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)
except Exception as db_err:
print(f"Database error during tag: {db_err}")
# Legacy fallback
try:
names = _load_json(NAMES_PATH)
names[req.filename] = clip_desc
_save_json(NAMES_PATH, names)
except: pass
return {"filename": req.filename, "clip_description": clip_desc, "tags": tags[:30]}
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:
return _load_json(NAMES_PATH)
@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}")
# Legacy fallback
try:
names = _load_json(NAMES_PATH)
names[filename] = name
_save_json(NAMES_PATH, names)
except: pass
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:
return _load_json(GROUPS_PATH)
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}")
# Legacy fallback
try:
groups = _load_json(GROUPS_PATH)
for fname in req.filenames:
groups[fname] = gid
_save_json(GROUPS_PATH, groups)
except: pass
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}")
# Legacy fallback
try:
groups = _load_json(GROUPS_PATH)
groups[req.filename] = gid
_save_json(GROUPS_PATH, groups)
except: pass
return {"filename": req.filename}
@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.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)
auto_name = name or naming.generate_associative_name(tags)
# 2. Embedding for source
embedding = embeddings.generate_embedding(file_path)
# 3. Register source in DB (optional, but good for tracking)
# We'll use the original filename or a timestamped one
database.upsert_person(filename, filepath=file_path, name=auto_name, clip_description=clip_desc, tags=tags, embedding=embedding, group_id=group_id)
# 4. Default behavior: Crop if needed
# We'll use default values from watcher
cropped_pil = _crop_to_bbox(pil)
# 5. Run prompts
for i, prompt in enumerate(prompts):
try:
# Use RGB for pipeline
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)
# Register output in DB
out_embedding = embeddings.generate_embedding(out_path)
database.upsert_person(out_name, filepath=out_path, name=auto_name, clip_description=clip_desc, embedding=out_embedding, group_id=group_id)
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 = naming.get_base_name(filename)
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.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")))