@@ -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+
9511032async 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