edit_api.py:

•
SAM2 fix — switched to SAM2ImagePredictor with a generous bbox (5% margin) instead of points. Bbox-based SAM2 captures the full subject including hair, glasses, and sandals since it doesn't clip with negative-point interference
•
Non-destructive remove-bg — writes <stem>.nobg.png sidecar, original file untouched; registers sidecar in DB under same group
•
New /images/{filename}/duplicate endpoint — copies file with a fresh timestamp name, same group
car.html:
•
sam2RemoveBg() — switches viewer to sidecar URL, auto-enables checkerboard; original file never modified
•
restoreBg() — purely client-side, reverts viewer to original URL (no API call, no file change)
•
Gallery cycling frozen while studio is open (guard in startGroupCycle interval callback)
•
Main page scrollbar hidden when studio opens (body.overflow = hidden), restored on close
•
Delete — two-step inline confirmation: first click arms the button red ("Confirm delete"), second click deletes; stays in studio and navigates to the next image; only closes if it was the last image in the group
•
Duplicate button in Info tab — copies image into same group and navigates to the duplicate immediately
This commit is contained in:
mike
2026-06-22 11:58:51 +02:00
parent 7beed86c9a
commit 8dfe7775ea
7 changed files with 2379 additions and 127 deletions

View File

@@ -647,7 +647,7 @@ def _faceswap_worker_ff(job_id: str, model_filename: str, video_name: str,
'--execution-providers', 'cuda',
# Limit to 2 threads: each thread owns a cuBLAS handle + workspace; more
# threads exhausts VRAM when ComfyUI is running concurrently on the same GPU.
#'--execution-thread-count', '2',
'--execution-thread-count', '2',
'--face-swapper-model', 'ghost_3_256',
# The default yolo_face detector at score 0.5 misses the extreme-angle /
# cropped close-up faces common in these POV template clips, so the swap
@@ -894,6 +894,8 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
try:
base_pil = Image.open(fpath).convert("RGB")
for prompt, pose in zip(prompts, poses):
if jobs[job_id].get("cancelled"):
return
try:
pil = base_pil
# Rotate 180° for poses that work better upside-down
@@ -1057,7 +1059,7 @@ def start_batch(req: BatchRequest):
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}
jobs[job_id] = {"status": "running", "total": total_tasks, "done": 0, "failed": 0, "cancelled": False}
t = threading.Thread(
target=_batch_worker,
args=(job_id, req.filenames, prompts, poses, req.seed, req.max_area, req.group_id),
@@ -1067,6 +1069,15 @@ def start_batch(req: BatchRequest):
return {"job_id": job_id, "total": total_tasks}
@app.delete("/batch/{job_id}")
def cancel_batch(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Job not found")
jobs[job_id]["cancelled"] = True
jobs[job_id]["status"] = "cancelled"
return {"status": "cancelled", "job_id": job_id}
class MultiRefRequest(BaseModel):
filenames: list[str] # 23 reference images; first is primary (image1)
prompt: str | list[str]
@@ -1914,12 +1925,14 @@ def _load_sam2():
def _apply_transparency_sam2(png_bytes: bytes) -> bytes:
"""Remove background with SAM2, point-prompted on the central subject; fallback to rembg.
"""Remove background with SAM2 bbox-based segmentation; fallback to rembg.
Prompts the predictor with positive points down the vertical center (where a
standing/seated subject lives) and negative points at the top corners and side
edges (background). This keeps the subject opaque instead of the old
largest-area heuristic, which selected the background in most portraits.
Mimics the reference approach (bbox → SAM2) but without an external
detector: we pass a generous bbox covering the central subject area.
For portraits/full-body shots the subject fills most of the frame, so a
5 % margin bbox reliably captures hair, glasses, and sandals without the
point-prompt clipping issues. multimask_output=True lets SAM2 propose
three masks; we pick the highest-scoring one.
"""
predictor = _load_sam2()
if predictor is False:
@@ -1930,27 +1943,19 @@ def _apply_transparency_sam2(png_bytes: bytes) -> bytes:
img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
arr = np.array(img)
h, w = arr.shape[:2]
point_coords = np.array([
[w * 0.50, h * 0.30], # subject (upper center)
[w * 0.50, h * 0.50], # subject (center)
[w * 0.50, h * 0.70], # subject (lower center)
[w * 0.04, h * 0.06], # background (top-left)
[w * 0.96, h * 0.06], # background (top-right)
[w * 0.03, h * 0.50], # background (mid-left edge)
[w * 0.97, h * 0.50], # background (mid-right edge)
], dtype=np.float32)
point_labels = np.array([1, 1, 1, 0, 0, 0, 0], dtype=np.int32)
# Generous bbox — 5 % margin H, 2 % margin V — covers the whole subject
box = np.array([[int(w * 0.05), int(h * 0.02),
int(w * 0.95), int(h * 0.98)]], dtype=np.float32)
with torch.inference_mode():
predictor.set_image(arr)
masks, scores, _ = predictor.predict(
point_coords=point_coords,
point_labels=point_labels,
box=box,
multimask_output=True,
)
if masks is None or len(masks) == 0:
return _apply_transparency(png_bytes)
best = masks[int(np.argmax(scores))]
mask_np = (best.astype(np.uint8) * 255)
mask_np = best.astype(np.uint8) * 255
rgba = img.convert("RGBA")
r, g, b, _ = rgba.split()
alpha = Image.fromarray(mask_np, mode="L")
@@ -1965,18 +1970,84 @@ def _apply_transparency_sam2(png_bytes: bytes) -> bytes:
@app.post("/remove-background-sam/{filename}")
def remove_background_sam(filename: str):
"""SAM2-based background removal (RGBA PNG). Falls back to rembg if SAM2 unavailable."""
"""SAM2-based background removal.
Writes the transparent result as a sidecar <stem>.nobg.png alongside the
original, which is left untouched. Returns the sidecar URL so the UI can
switch the viewer without touching the source file.
Falls back to rembg when SAM2 is unavailable.
"""
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]
output_dir = os.path.dirname(path)
stem = os.path.splitext(filename)[0]
nobg_filename = f"{stem}.nobg.png"
nobg_path = os.path.join(output_dir, nobg_filename)
with open(path, "rb") as f:
png_bytes = f.read()
transparent_png = _apply_transparency_sam2(png_bytes)
with open(path, "wb") as f:
with open(nobg_path, "wb") as f:
f.write(transparent_png)
# Register sidecar in DB so it appears in the same group
group_id = person[1]
database.upsert_person(nobg_filename, filepath=nobg_path,
group_id=group_id, has_background=False)
used_sam2 = _sam2_predictor is not False and _sam2_predictor is not None
return {"status": "success", "filename": filename, "used_sam2": used_sam2}
return {
"status": "success",
"filename": filename,
"nobg_filename": nobg_filename,
"nobg_url": f"/output/{nobg_filename}",
"used_sam2": used_sam2,
}
@app.post("/images/{filename}/autocrop")
def autocrop_image(filename: str):
"""Crop away transparent borders from an image in-place."""
import numpy as np
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]
img = Image.open(path).convert("RGBA")
arr = np.array(img)
alpha = arr[:, :, 3]
rows = np.any(alpha > 0, axis=1)
cols = np.any(alpha > 0, axis=0)
if not rows.any():
raise HTTPException(400, "Image is fully transparent")
rmin, rmax = np.where(rows)[0][[0, -1]]
cmin, cmax = np.where(cols)[0][[0, -1]]
cropped = img.crop((cmin, rmin, cmax + 1, rmax + 1))
cropped.save(path, format="PNG")
return {"status": "success", "filename": filename, "box": [int(cmin), int(rmin), int(cmax+1), int(rmax+1)]}
@app.post("/images/{filename}/duplicate")
def duplicate_image(filename: str):
"""Copy an image into the same group with a fresh timestamp-based filename."""
import shutil as _shutil
from datetime import datetime as _dt
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]
output_dir = os.path.dirname(path)
ext = os.path.splitext(filename)[1] or ".png"
ts = _dt.now().strftime("%Y%m%d_%H%M%S")
stem = os.path.splitext(filename)[0]
new_filename = f"{ts}_dup_{stem}{ext}"
new_path = os.path.join(output_dir, new_filename)
_shutil.copy2(path, new_path)
group_id = person[1]
database.upsert_person(new_filename, filepath=new_path, group_id=group_id)
return {"status": "success", "new_filename": new_filename, "new_url": f"/output/{new_filename}"}
@app.post("/restore-background/{filename}")