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:
@@ -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] # 2–3 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}")
|
||||
|
||||
Reference in New Issue
Block a user