Skip to content

Commit 3aa7b82

Browse files
committed
Fix demasking: switch to SD inpainting with auto face mask, fix CodeFormer fidelity 1.0->0.5
1 parent ed0b4c7 commit 3aa7b82

2 files changed

Lines changed: 222 additions & 109 deletions

File tree

api/main.py

Lines changed: 207 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -948,17 +948,111 @@ async def api_plugin_delete(
948948

949949

950950
@app.post("/sh-api/demask")
951+
def _generate_face_coverage_mask(image_bytes: bytes) -> bytes:
952+
"""
953+
Use OpenCV face detection to locate the face(s) in the image and return
954+
a PNG mask where the face-covering region (lower ~65 % of each face bbox,
955+
widened slightly) is WHITE (inpaint here) and everything else is BLACK
956+
(leave untouched).
957+
958+
If no face is detected we fall back to masking a generous central ellipse
959+
so the inpainting model still has something useful to work with.
960+
"""
961+
import cv2
962+
import numpy as np
963+
from PIL import Image as PILImage
964+
from PIL import ImageDraw, ImageFilter
965+
966+
nparr = np.frombuffer(image_bytes, np.uint8)
967+
img_cv = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
968+
969+
if img_cv is None:
970+
# Cannot decode — return a solid-centre ellipse mask as emergency fallback
971+
pil_img = PILImage.open(BytesIO(image_bytes)).convert("RGB")
972+
w, h = pil_img.size
973+
mask = PILImage.new("L", (w, h), 0)
974+
draw = ImageDraw.Draw(mask)
975+
draw.ellipse([w // 4, h // 4, 3 * w // 4, 3 * h // 4], fill=255)
976+
buf = BytesIO()
977+
mask.save(buf, format="PNG")
978+
return buf.getvalue()
979+
980+
h_img, w_img = img_cv.shape[:2]
981+
gray = cv2.cvtColor(img_cv, cv2.COLOR_BGR2GRAY)
982+
gray = cv2.equalizeHist(gray)
983+
984+
face_cascade = cv2.CascadeClassifier(
985+
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
986+
)
987+
# Also try profile cascade for side-on faces
988+
profile_cascade = cv2.CascadeClassifier(
989+
cv2.data.haarcascades + "haarcascade_profileface.xml"
990+
)
991+
992+
faces = face_cascade.detectMultiScale(
993+
gray, scaleFactor=1.05, minNeighbors=4, minSize=(60, 60)
994+
)
995+
if len(faces) == 0:
996+
faces = profile_cascade.detectMultiScale(
997+
gray, scaleFactor=1.05, minNeighbors=4, minSize=(60, 60)
998+
)
999+
1000+
mask = PILImage.new("L", (w_img, h_img), 0)
1001+
draw = ImageDraw.Draw(mask)
1002+
1003+
if len(faces) > 0:
1004+
for fx, fy, fw, fh in faces:
1005+
# Mask from ~35 % down (just below the eyes) to just below the chin
1006+
# with a small horizontal padding so we catch the jaw edges
1007+
padding_x = int(fw * 0.08)
1008+
top = fy + int(fh * 0.35)
1009+
bottom = fy + fh + int(fh * 0.08)
1010+
left = max(0, fx - padding_x)
1011+
right = min(w_img, fx + fw + padding_x)
1012+
draw.rectangle([left, top, right, bottom], fill=255)
1013+
else:
1014+
# No face found — mask a generous centre region
1015+
print("[Demask] No face detected; using centre-region fallback mask.")
1016+
cx, cy = w_img // 2, h_img // 2
1017+
draw.ellipse(
1018+
[cx - w_img // 4, cy - h_img // 6, cx + w_img // 4, cy + h_img // 3],
1019+
fill=255,
1020+
)
1021+
1022+
# Feather edges slightly so the inpainted region blends naturally
1023+
mask = mask.filter(ImageFilter.GaussianBlur(radius=6))
1024+
# Re-threshold to keep it binary-ish after blur
1025+
mask = mask.point(lambda p: 255 if p > 30 else 0)
1026+
1027+
buf = BytesIO()
1028+
mask.save(buf, format="PNG")
1029+
return buf.getvalue()
1030+
1031+
9511032
async def api_demask(
9521033
file: UploadFile = File(...),
9531034
x_plugin_token: Optional[str] = Header(default=None, alias="X-Plugin-Token"),
9541035
):
9551036
"""
956-
AI demasking using Replicate library for automatic version management.
957-
Performs mask removal followed by face restoration for forensic clarity.
1037+
AI demasking pipeline (Replicate):
1038+
1039+
Step 1 — SD Inpainting (stability-ai/stable-diffusion-inpainting)
1040+
An OpenCV face detector auto-generates a tight mask over the
1041+
face-covering region. SD inpainting ONLY modifies that region,
1042+
so hair, forehead, skin tone, eyebrows and all surrounding identity
1043+
cues are preserved — this is what prevents gender swaps and
1044+
full-image hallucinations.
1045+
1046+
Step 2 — CodeFormer face restoration (fidelity 0.5)
1047+
Sharpens the inpainted face region. Fidelity is kept at 0.5 so
1048+
CodeFormer enhances rather than replaces the generated face.
1049+
1050+
Fallback — If SD inpainting is unavailable, instruct-pix2pix is tried
1051+
with tighter guidance values before giving up.
9581052
"""
9591053
require_admin(x_plugin_token)
9601054

961-
# 1. Get Replicate API Token
1055+
# ── 1. Replicate token ────────────────────────────────────────────────────
9621056
settings = settings_store.load()
9631057
replicate_token = (os.getenv("REPLICATE_API_TOKEN") or "").strip() or settings.get(
9641058
"replicate_api_token"
@@ -967,6 +1061,7 @@ async def api_demask(
9671061
replicate_token = replicate_token.get("value")
9681062

9691063
if not replicate_token:
1064+
# Try local face-restoration service as last resort
9701065
try:
9711066
content = await file.read()
9721067
restored_bytes = await restore_face(content, strength=0.7)
@@ -975,158 +1070,171 @@ async def api_demask(
9751070
BytesIO(restored_bytes), media_type="image/png"
9761071
)
9771072
except Exception as e:
978-
print(f"[DEBUG] Local demask fallback failed: {e}")
979-
1073+
print(f"[Demask] Local fallback failed: {e}")
9801074
raise HTTPException(
9811075
status_code=400,
982-
detail="AI service unavailable. Configure REPLICATE_API_TOKEN or SOCIAL_HUNT_FACE_AI_URL.",
1076+
detail="AI service unavailable. Configure REPLICATE_API_TOKEN in Settings.",
9831077
)
9841078

9851079
try:
986-
# 2. Prepare the image
1080+
# ── 2. Read & encode image ────────────────────────────────────────────
9871081
content = await file.read()
988-
print(f"[DEBUG] Demasking: processing {file.filename}")
1082+
print(f"[Demask] Processing: {file.filename} ({len(content)} bytes)")
9891083

990-
# Prefer direct Base64 encoding for better reliability with Replicate model containers
991-
b64_img = (
992-
f"data:{file.content_type};base64,{base64.b64encode(content).decode()}"
993-
)
994-
995-
# 3. Step 1: Remove the mask using Pix2Pix
996-
print("[DEBUG] Demasking: step 1 (instruct-pix2pix)...")
1084+
mime = file.content_type or "image/jpeg"
1085+
b64_img = f"data:{mime};base64,{base64.b64encode(content).decode()}"
9971086

9981087
rep_client = replicate.Client(api_token=replicate_token)
9991088

1000-
# Programmatically fetch latest versions to avoid 404 errors
1089+
# ── 3. Auto-generate face coverage mask ───────────────────────────────
1090+
print("[Demask] Generating face coverage mask…")
1091+
mask_bytes = await asyncio.to_thread(_generate_face_coverage_mask, content)
1092+
b64_mask = f"data:image/png;base64,{base64.b64encode(mask_bytes).decode()}"
1093+
print("[Demask] Mask generated.")
1094+
1095+
# ── 4. Fetch model versions ───────────────────────────────────────────
1096+
inpaint_model_id = "stability-ai/stable-diffusion-inpainting"
1097+
codeformer_model_id = "sczhou/codeformer"
1098+
10011099
try:
1002-
model_pix2pix = await asyncio.to_thread(
1003-
rep_client.models.get, "timothybrooks/instruct-pix2pix"
1004-
)
1005-
model_codeformer = await asyncio.to_thread(
1006-
rep_client.models.get, "sczhou/codeformer"
1100+
m_inpaint = await asyncio.to_thread(rep_client.models.get, inpaint_model_id)
1101+
m_codeformer = await asyncio.to_thread(
1102+
rep_client.models.get, codeformer_model_id
10071103
)
1008-
v_pix2pix = model_pix2pix.latest_version.id
1009-
v_codeformer = model_codeformer.latest_version.id
1104+
v_inpaint = m_inpaint.latest_version.id
1105+
v_codeformer = m_codeformer.latest_version.id
10101106
except Exception as me:
1011-
print(f"[ERROR] Failed to fetch Replicate model metadata: {me}")
1012-
# Use safe defaults if metadata fetch fails
1013-
v_pix2pix = (
1014-
"30c1d0b916a6f8efce20493f5d61ee27491ab2a60437c13c588468b9810ec23f"
1107+
print(
1108+
f"[Demask] Could not fetch model metadata: {me} — using pinned versions."
1109+
)
1110+
v_inpaint = (
1111+
"a9758cbfbd5f3c2094457d996681af52552901775aa2d6dd0b17fd15df959bef"
10151112
)
10161113
v_codeformer = (
10171114
"7de2ea4a352033cfa2f21683c7a9511da922ec5ad9f9e61298d0b3dd16742617"
10181115
)
10191116

1117+
# ── 5. Step 1: SD Inpainting — only the masked region changes ─────────
1118+
print("[Demask] Step 1 — SD inpainting…")
1119+
inpainted_url = ""
10201120
try:
1021-
# Reverting to Pix2Pix with optimized forensic parameters to fix identity loss and distortion
10221121
output_1 = await asyncio.to_thread(
10231122
rep_client.run,
1024-
f"timothybrooks/instruct-pix2pix:{v_pix2pix}",
1123+
f"{inpaint_model_id}:{v_inpaint}",
10251124
input={
10261125
"image": b64_img,
1027-
"prompt": "remove only the face covering (mask, balaclava, ski mask, sunglasses); keep the same people, clothing, pose, background, and number of people unchanged; preserve identity; realistic face",
1028-
"negative_prompt": "new person, different identity, change gender, change ethnicity, extra faces, extra people, cloned face, multiple heads, distorted, blurry, cartoon, mask remains, makeup, jungle, trees, nature, psychedelic, abstract, colorful, mutation, deformed, ugly, bad anatomy, bad proportions, extra limbs, fused fingers, too many fingers, long neck",
1029-
"num_inference_steps": 25,
1030-
"image_guidance_scale": 2.4, # Preserve structure to reduce hallucinations
1031-
"guidance_scale": 3.0, # Reduce aggressive edits
1126+
"mask": b64_mask,
1127+
# Prompt: describe the TARGET state of the masked region only
1128+
"prompt": (
1129+
"realistic human face, natural skin, clear facial features, "
1130+
"photo-realistic, no face covering, no mask, no balaclava, "
1131+
"consistent skin tone, same ethnicity, same gender"
1132+
),
1133+
"negative_prompt": (
1134+
"mask, balaclava, ski mask, face covering, sunglasses, "
1135+
"different gender, different ethnicity, distorted, blurry, "
1136+
"cartoon, painting, extra faces, mutation, deformed, "
1137+
"bad anatomy, watermark, text"
1138+
),
1139+
"num_outputs": 1,
1140+
"num_inference_steps": 50,
1141+
"guidance_scale": 7.5,
1142+
# scheduler: DPMSolverMultistep gives crisp faces
1143+
"scheduler": "DPMSolverMultistep",
10321144
},
10331145
)
1034-
# Ensure output is converted from FileOutput object to string URL
10351146
if isinstance(output_1, list) and len(output_1) > 0:
10361147
inpainted_url = str(output_1[0])
10371148
else:
1038-
inpainted_url = str(output_1)
1149+
inpainted_url = str(output_1) if output_1 else ""
10391150
except Exception as e:
1040-
print(f"[ERROR] Demasking Step 1 failed: {e}")
1151+
print(f"[Demask] SD inpainting failed: {e}")
10411152

1042-
# Fallback to Catbox if Base64 failed (sometimes happens with large payloads or 404s)
1043-
print("[DEBUG] Attempting Catbox fallback for Step 1...")
1044-
inpainted_url = ""
1153+
# ── 5b. Fallback: pix2pix with corrected guidance values ─────────────
1154+
if not inpainted_url:
1155+
print("[Demask] Falling back to instruct-pix2pix…")
10451156
try:
1046-
async with httpx.AsyncClient() as hc:
1047-
files = {
1048-
"fileToUpload": (file.filename, content, file.content_type)
1049-
}
1050-
data = {"reqtype": "fileupload", "userhash": ""}
1051-
cres = await hc.post(
1052-
"https://catbox.moe/user/api.php", data=data, files=files
1053-
)
1054-
if cres.status_code == 200:
1055-
file_url = cres.text.strip()
1056-
output_1 = await asyncio.to_thread(
1057-
rep_client.run,
1058-
f"timothybrooks/instruct-pix2pix:{v_pix2pix}",
1059-
input={
1060-
"image": file_url,
1061-
"prompt": "remove only the face covering (mask, balaclava, ski mask, sunglasses); keep the same people, clothing, pose, background, and number of people unchanged; preserve identity; realistic face",
1062-
"negative_prompt": "new person, different identity, change gender, change ethnicity, extra faces, extra people, cloned face, multiple heads, distorted, blurry, cartoon, mask remains, makeup, jungle, trees, nature, psychedelic, abstract, colorful, mutation, deformed, ugly, bad anatomy, bad proportions, extra limbs, fused fingers, too many fingers, long neck",
1063-
"num_inference_steps": 25,
1064-
"image_guidance_scale": 2.4,
1065-
"guidance_scale": 3.0,
1066-
},
1067-
)
1068-
# Ensure output is converted from FileOutput object to string URL
1069-
if isinstance(output_1, list) and len(output_1) > 0:
1070-
inpainted_url = str(output_1[0])
1071-
else:
1072-
inpainted_url = str(output_1)
1073-
except Exception as fe:
1074-
print(f"[ERROR] Fallback failed: {fe}")
1075-
1076-
if not inpainted_url:
1077-
raise HTTPException(
1078-
status_code=500, detail=f"AI Step 1 failed: {str(e)}"
1157+
m_p2p = await asyncio.to_thread(
1158+
rep_client.models.get, "timothybrooks/instruct-pix2pix"
1159+
)
1160+
v_p2p = m_p2p.latest_version.id
1161+
except Exception:
1162+
v_p2p = (
1163+
"30c1d0b916a6f8efce20493f5d61ee27491ab2a60437c13c588468b9810ec23f"
1164+
)
1165+
1166+
try:
1167+
output_fb = await asyncio.to_thread(
1168+
rep_client.run,
1169+
f"timothybrooks/instruct-pix2pix:{v_p2p}",
1170+
input={
1171+
"image": b64_img,
1172+
"prompt": (
1173+
"reveal the face beneath the mask or covering; "
1174+
"keep gender, ethnicity, hair, clothing and background "
1175+
"completely unchanged; realistic photo"
1176+
),
1177+
"negative_prompt": (
1178+
"change gender, change ethnicity, new person, hallucinate, "
1179+
"different identity, extra faces, distorted, cartoon, blurry, "
1180+
"mask remains, sunglasses, face covering"
1181+
),
1182+
"num_inference_steps": 50,
1183+
# Higher image_guidance preserves structure; higher guidance
1184+
# makes the model actually follow the instruction
1185+
"image_guidance_scale": 2.0,
1186+
"guidance_scale": 8.0,
1187+
},
10791188
)
1189+
if isinstance(output_fb, list) and len(output_fb) > 0:
1190+
inpainted_url = str(output_fb[0])
1191+
else:
1192+
inpainted_url = str(output_fb) if output_fb else ""
1193+
except Exception as e2:
1194+
print(f"[Demask] pix2pix fallback also failed: {e2}")
10801195

10811196
if not inpainted_url:
1082-
raise HTTPException(status_code=504, detail="AI Step 1 returned no output.")
1197+
raise HTTPException(
1198+
status_code=500, detail="Step 1 (inpainting) produced no output."
1199+
)
10831200

1084-
print(f"[DEBUG] Demasking: step 1 complete, url: {inpainted_url}")
1201+
print(f"[Demask] Step 1 complete {inpainted_url}")
10851202

1086-
# 4. Step 2: Face Restoration (CodeFormer)
1087-
print("[DEBUG] Demasking: step 2 (codeformer)...")
1203+
# ── 6. Step 2: CodeFormer — sharpen the inpainted face ───────────────
1204+
# fidelity=0.5 means CodeFormer ENHANCES the existing face rather than
1205+
# replacing it wholesale (fidelity=1.0 was causing gender swaps).
1206+
print("[Demask] Step 2 — CodeFormer…")
10881207
try:
10891208
output_2 = await asyncio.to_thread(
10901209
rep_client.run,
1091-
f"sczhou/codeformer:{v_codeformer}",
1210+
f"{codeformer_model_id}:{v_codeformer}",
10921211
input={
10931212
"image": inpainted_url,
10941213
"upscale": 1,
10951214
"face_upsample": True,
1096-
"codeformer_fidelity": 1.0, # Maximized fidelity to further reduce hallucinations
1215+
"codeformer_fidelity": 0.5, # 0.5 = enhance, not replace
10971216
},
10981217
)
1099-
# Ensure output is converted from FileOutput object to string URL
11001218
if isinstance(output_2, list) and len(output_2) > 0:
1101-
final_output_url = str(output_2[0])
1219+
final_url = str(output_2[0])
11021220
else:
1103-
final_output_url = str(output_2) if output_2 else None
1221+
final_url = str(output_2) if output_2 else None
11041222

1105-
if final_output_url:
1106-
async with httpx.AsyncClient() as hc:
1107-
img_res = await hc.get(final_output_url)
1108-
return StreamingResponse(
1109-
BytesIO(img_res.content), media_type="image/png"
1110-
)
1111-
1112-
# Fallback to step 1 result
1223+
fetch_url = final_url if final_url else inpainted_url
11131224
async with httpx.AsyncClient() as hc:
1114-
img_res = await hc.get(inpainted_url)
1115-
return StreamingResponse(
1116-
BytesIO(img_res.content), media_type="image/png"
1117-
)
1225+
img_res = await hc.get(fetch_url)
1226+
return StreamingResponse(BytesIO(img_res.content), media_type="image/png")
1227+
11181228
except Exception as e:
1119-
print(f"[WARN] Demasking Step 2 failed: {e}. Returning Step 1 result.")
1229+
print(f"[Demask] CodeFormer failed ({e}); returning Step 1 result.")
11201230
async with httpx.AsyncClient() as hc:
11211231
img_res = await hc.get(inpainted_url)
1122-
return StreamingResponse(
1123-
BytesIO(img_res.content), media_type="image/png"
1124-
)
1232+
return StreamingResponse(BytesIO(img_res.content), media_type="image/png")
11251233

11261234
except HTTPException:
11271235
raise
11281236
except Exception as e:
1129-
print(f"[ERROR] Demasking failed: {e}")
1237+
print(f"[Demask] Unhandled error: {e}")
11301238
raise HTTPException(status_code=500, detail=str(e))
11311239

11321240

0 commit comments

Comments
 (0)