The previous SAM2 full-frame bbox approach inverts the mask on black-background images. When Qwen renders black background (≈75% of pixels are black), SAM2 scores the large dark region as the "most prominent object" and selects it — making the background opaque and the person transparent. That's why the output looked like a white silhouette: transparent person pixels → viewer shows white.

New _apply_transparency_black_bg function (called when bg_removal=sam2):
1.
Threshold — any pixel with max-channel > 25 = person. Finds the person's exact bounding box without any model confusion.
2.
SAM2 with tight person bbox — feeds SAM2 the person-specific box instead of the full frame. SAM2 now segments within the person area for clean sub-pixel edges.
3.
Coverage sanity — accepts SAM2 only if coverage is within ±30pp of the threshold estimate; rejects inverted-mask failures.
4.
Threshold mask fallback — if SAM2 errors or diverges, uses the threshold mask with Gaussian edge blur (r=2).
Test result: Person RGB mean (146, 101, 86) — correct skin tones. 74.5% transparent background, 24% opaque person. ✓
Test results validated:
•
rembg path: perfect cutout (hair bun, earring, sneakers, clean edges)
•
SAM2-on-black path: complete silhouette mask at 74% coverage — full body, shoes and hair included, no holes
To switch to SAM2 mode: "bg_removal": "sam2" in config.json. No restart needed — the config is read per-request.
This commit is contained in:
mike
2026-06-23 21:52:59 +02:00
parent 3d44c7aba4
commit 188682b4c3
6 changed files with 446 additions and 81 deletions

View File

@@ -154,6 +154,8 @@ def on_startup():
print(f"[output] mounted {out_dir} → /output")
except Exception as e:
print(f"[output] mount warning: {e}")
# Write initial static data files (synchronous — ensures files exist before first request)
_write_all_static()
# --- helpers ----------------------------------------------------------------
@@ -949,6 +951,106 @@ def _move_to_trash(filepath: str):
print(f"Error moving {filepath} to trash: {e}")
# --- static data files -------------------------------------------------------
_static_write_lock = threading.Lock()
def _write_json(path: str, data) -> None:
"""Atomic JSON write: write to .tmp then os.replace (no partial reads)."""
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f)
os.replace(tmp, path)
def _write_all_static() -> None:
"""Regenerate <output_dir>/_data/{images,names,groups,group-names,videos}.json.
Called on startup and after every mutation so the browser can\
fetch pre-computed JSON instead of hitting the DB on each page load.
Uses a lock so concurrent invalidations don't race each other.
"""
with _static_write_lock:
try:
output_dir = _load_output_dir()
data_dir = os.path.join(output_dir, "_data")
os.makedirs(data_dir, exist_ok=True)
# Single DB query reused for images / names / groups
persons = database.list_persons(include_archived=True)
# images.json — all images (frontend filters by .archived field)
db_images = []
for p in persons:
fpath = os.path.join(output_dir, p[0])
if not os.path.exists(fpath):
continue
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],
"content_type": p[12] or "image",
"faceswap_source_video": p[13],
"archived": bool(p[14]) if p[14] else False,
})
try:
db_images.sort(
key=lambda x: os.path.getmtime(os.path.join(output_dir, x["filename"])),
reverse=True,
)
except Exception:
pass
_write_json(os.path.join(data_dir, "images.json"), {"images": db_images})
# names.json — {filename: name}
_write_json(os.path.join(data_dir, "names.json"),
{p[0]: p[1] for p in persons if p[1]})
# groups.json — {filename: group_id}
_write_json(os.path.join(data_dir, "groups.json"),
{p[0]: p[2] for p in persons if p[2]})
# group-names.json — {group_id: display_name}
_write_json(os.path.join(data_dir, "group-names.json"),
database.get_all_group_names())
# videos.json — wireframe template videos
wireframe_dir = _load_wireframe_dir()
videos = []
if os.path.isdir(wireframe_dir):
videos = [f for f in sorted(os.listdir(wireframe_dir))
if f.lower().endswith(VIDEO_EXTENSIONS) and not f.startswith('.')]
_write_json(os.path.join(data_dir, "videos.json"),
{"videos": videos, "wireframe_dir": wireframe_dir})
# config.json — current config snapshot
try:
with open(CONFIG_PATH, "r") as _f:
_write_json(os.path.join(data_dir, "config.json"), json.load(_f))
except Exception:
pass
except Exception as e:
print(f"[static] write_all error: {e}")
def _invalidate_static() -> None:
"""Spawn a daemon thread to regenerate all static data files (non-blocking)."""
threading.Thread(target=_write_all_static, daemon=True).start()
# -----------------------------------------------------------------------------
def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
seed: int, max_area: int, group_id: str | None = None,
wireframe_ref: str | None = None, wireframe_time: float = 0.5):
@@ -1138,6 +1240,7 @@ def update_config(update: ConfigUpdate):
conf["seed"] = update.seed
with open(CONFIG_PATH, "w") as f:
json.dump(conf, f, indent=2)
_invalidate_static()
return {"prompt": conf["prompt"], "seed": conf["seed"]}
@@ -1525,6 +1628,7 @@ def set_name(filename: str, body: dict):
except Exception as db_err:
print(f"Database error in set_name: {db_err}")
_invalidate_static()
return {"filename": filename, "name": name}
@@ -1553,6 +1657,7 @@ def merge_groups(req: MergeRequest):
except Exception as db_err:
print(f"Database error in merge: {db_err}")
_invalidate_static()
return {"group_id": gid, "files": req.filenames}
@@ -1568,6 +1673,7 @@ def extract_from_group(req: ExtractRequest):
except Exception as db_err:
print(f"Database error in extract: {db_err}")
_invalidate_static()
return {"filename": req.filename}
@@ -1586,6 +1692,7 @@ def set_group_name(group_id: str, body: dict):
database.set_group_name(group_id, name or None)
except Exception as e:
raise HTTPException(500, str(e))
_invalidate_static()
return {"group_id": group_id, "name": name}
@@ -1608,6 +1715,7 @@ def set_group_order(group_id: str, req: GroupOrderRequest):
database.set_group_order(group_id, req.filenames)
except Exception as e:
raise HTTPException(500, str(e))
_invalidate_static()
return {"group_id": group_id, "filenames": req.filenames}
@@ -1647,6 +1755,8 @@ def db_cleanup():
if not os.path.exists(fpath):
database.delete_person(p[0])
removed.append(p[0])
if removed:
_invalidate_static()
return {"removed": len(removed), "filenames": removed}
@@ -1770,6 +1880,8 @@ def _process_upload(file_path: str, filename: str, prompts: list[str], name: str
except Exception as e:
print(f"Error in _process_upload for {filename}: {e}")
finally:
_invalidate_static()
@app.post("/upload")
@@ -1804,6 +1916,7 @@ def upload_image(
sort_order = database.get_next_sort_order(group_id)
database.upsert_person(filename, filepath=file_path, group_id=group_id,
sort_order=sort_order)
_invalidate_static()
return {"status": "added", "filename": filename, "group_id": group_id}
prompt_list = [p.strip() for p in prompts.split(",") if p.strip()]
@@ -1851,6 +1964,7 @@ def set_image_hidden(filename: str, body: dict):
database.set_hidden(filename, hidden)
except Exception as e:
raise HTTPException(500, str(e))
_invalidate_static()
return {"filename": filename, "hidden": hidden}
@@ -1860,6 +1974,7 @@ def archive_image(filename: str):
database.set_archived(filename, True)
except Exception as e:
raise HTTPException(500, str(e))
_invalidate_static()
return {"filename": filename, "archived": True}
@@ -1869,6 +1984,7 @@ def unarchive_image(filename: str):
database.set_archived(filename, False)
except Exception as e:
raise HTTPException(500, str(e))
_invalidate_static()
return {"filename": filename, "archived": False}
@@ -1884,6 +2000,7 @@ def set_image_preferred(filename: str, background_tasks: BackgroundTasks):
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)
_invalidate_static()
fpath = os.path.join(_load_output_dir(), filename)
if os.path.exists(fpath):
background_tasks.add_task(_extract_face_bg, filename, fpath)
@@ -1929,6 +2046,7 @@ def delete_image(filename: str):
_move_to_trash(person[5])
database.delete_person(filename)
_invalidate_static()
return {"status": "deleted", "filename": filename}
@@ -1938,8 +2056,9 @@ def delete_group(group_id: str):
for filename, filepath in files:
if filepath and os.path.exists(filepath):
_move_to_trash(filepath)
database.delete_group(group_id)
_invalidate_static()
return {"status": "deleted", "group_id": group_id}
@@ -2397,6 +2516,7 @@ def duplicate_image(filename: str):
has_clothing=person[13],
source_refs=json.dumps([filename]), # original is the reference
)
_invalidate_static()
return {"status": "success", "new_filename": new_filename, "new_url": f"/output/{new_filename}"}