diff --git a/README.md b/README.md index 93f881d..bd9c316 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Evolved from the foundations of the [Refacer](https://github.com/xaviviro/reface * **Single Face** (Fast): all faces are replaced with a single face. Ideal for images, GIFs or videos with a single face * **Multiple Faces** (Fast): faces are replaced with the faces you provide based on their order from left to right * **Faces by Match** (Slower): faces are first detected and replaced with the faces you provide. +* Reface ratio: full face to half face. * Improved GPU detection * Support for multi-page TIFF * Uses local Gradio cache with auto-cleanup on startup diff --git a/app.py b/app.py index 9919968..f28ad2b 100644 --- a/app.py +++ b/app.py @@ -56,8 +56,9 @@ def run_image(*vars): image_path = vars[0] origins = vars[1:(num_faces+1)] destinations = vars[(num_faces+1):(num_faces*2)+1] - thresholds = vars[(num_faces*2)+1:-1] - face_mode = vars[-1] + thresholds = vars[(num_faces*2)+1:-2] + face_mode = vars[-2] + partial_reface_ratio = vars[-1] disable_similarity = (face_mode in ["Single Face", "Multiple Faces"]) multiple_faces_mode = (face_mode == "Multiple Faces") @@ -71,15 +72,16 @@ def run_image(*vars): 'threshold': thresholds[k] if not multiple_faces_mode else 0.0 }) - return refacer.reface_image(image_path, faces, disable_similarity=disable_similarity, multiple_faces_mode=multiple_faces_mode) + return refacer.reface_image(image_path, faces, disable_similarity=disable_similarity, multiple_faces_mode=multiple_faces_mode, partial_reface_ratio=partial_reface_ratio) def run(*vars): video_path = vars[0] origins = vars[1:(num_faces+1)] destinations = vars[(num_faces+1):(num_faces*2)+1] - thresholds = vars[(num_faces*2)+1:-2] - preview = vars[-2] - face_mode = vars[-1] + thresholds = vars[(num_faces*2)+1:-3] + preview = vars[-3] + face_mode = vars[-2] + partial_reface_ratio = vars[-1] disable_similarity = (face_mode in ["Single Face", "Multiple Faces"]) multiple_faces_mode = (face_mode == "Multiple Faces") @@ -93,7 +95,7 @@ def run(*vars): 'threshold': thresholds[k] if not multiple_faces_mode else 0.0 }) - mp4_path, gif_path = refacer.reface(video_path, faces, preview=preview, disable_similarity=disable_similarity, multiple_faces_mode=multiple_faces_mode) + mp4_path, gif_path = refacer.reface(video_path, faces, preview=preview, disable_similarity=disable_similarity, multiple_faces_mode=multiple_faces_mode, partial_reface_ratio=partial_reface_ratio) return mp4_path, gif_path if gif_path else None def load_first_frame(filepath): @@ -106,36 +108,26 @@ def extract_faces_auto(filepath, refacer_instance, max_faces=5, isvideo=False): if filepath is None: return [None] * max_faces - # Check if video is too large - if isvideo: - if os.path.getsize(filepath) > 5 * 1024 * 1024: # larger than 5MB - print("Video too large for auto-extract, skipping face extraction.") - return [None] * max_faces + if isvideo and os.path.getsize(filepath) > 5 * 1024 * 1024: + print("Video too large for auto-extract, skipping face extraction.") + return [None] * max_faces - # Load first frame frame = load_first_frame(filepath) if frame is None: return [None] * max_faces - print("Loaded frame shape:", frame.shape) - - # Handle weird TIFF/multipage dimensions while len(frame.shape) > 3: - frame = frame[0] # Keep taking the first slice until (H, W, C) - - print("Fixed frame shape:", frame.shape) + frame = frame[0] if frame.shape[-1] != 3: raise ValueError(f"Expected last dimension to be 3 (RGB), but got {frame.shape[-1]}") - # Create temp image inside ./tmp temp_image_path = os.path.join("./tmp", f"temp_face_extract_{int(time.time() * 1000)}.png") Image.fromarray(frame).save(temp_image_path) try: faces = refacer_instance.extract_faces_from_image(temp_image_path, max_faces=max_faces) - output_faces = faces + [None] * (max_faces - len(faces)) - return output_faces + return faces + [None] * (max_faces - len(faces)) finally: if os.path.exists(temp_image_path): try: @@ -154,9 +146,15 @@ def toggle_tabs_and_faces(mode, face_tabs, origin_faces): tab_updates = [gr.update(visible=True) for _ in range(len(face_tabs))] origin_updates = [gr.update(visible=True) for _ in range(len(origin_faces))] return tab_updates + origin_updates + +def handle_tif_preview(filepath): + if filepath is None: + return None + preview_path = os.path.join("./tmp", f"tif_preview_{int(time.time() * 1000)}.jpg") + Image.open(filepath).convert('RGB').save(preview_path) + return preview_path # --- UI --- - theme = gr.themes.Base(primary_hue="blue", secondary_hue="cyan") with gr.Blocks(theme=theme, title="NeoRefacer - AI Refacer") as demo: @@ -179,11 +177,8 @@ with gr.Blocks(theme=theme, title="NeoRefacer - AI Refacer") as demo: image_output = gr.Image(label="Refaced image", interactive=False, type="filepath") with gr.Row(): - face_mode_image = gr.Radio( - choices=["Single Face", "Multiple Faces", "Faces By Match"], - value="Single Face", - label="Replacement Mode" - ) + face_mode_image = gr.Radio(["Single Face", "Multiple Faces", "Faces By Match"], value="Single Face", label="Replacement Mode") + partial_reface_ratio_image = gr.Slider(label="Reface Ratio (0 = Full Face, 0.5 = Half Face)", minimum=0.0, maximum=0.5, value=0.0, step=0.1) image_btn = gr.Button("Reface Image", variant="primary") origin_image, destination_image, thresholds_image, face_tabs_image = [], [], [], [] @@ -199,29 +194,12 @@ with gr.Blocks(theme=theme, title="NeoRefacer - AI Refacer") as demo: thresholds_image.append(threshold) face_tabs_image.append(tab) - face_mode_image.change( - fn=lambda mode: toggle_tabs_and_faces(mode, face_tabs_image, origin_image), - inputs=[face_mode_image], - outputs=face_tabs_image + origin_image - ) + face_mode_image.change(fn=lambda mode: toggle_tabs_and_faces(mode, face_tabs_image, origin_image), inputs=[face_mode_image], outputs=face_tabs_image + origin_image) + demo.load(fn=lambda: toggle_tabs_and_faces("Single Face", face_tabs_image, origin_image), inputs=None, outputs=face_tabs_image + origin_image) - demo.load( - fn=lambda: toggle_tabs_and_faces("Single Face", face_tabs_image, origin_image), - inputs=None, - outputs=face_tabs_image + origin_image - ) - - image_btn.click( - fn=run_image, - inputs=[image_input] + origin_image + destination_image + thresholds_image + [face_mode_image], - outputs=[image_output] - ) - - image_input.change( - fn=lambda filepath: extract_faces_auto(filepath, refacer, max_faces=num_faces), - inputs=image_input, - outputs=origin_image - ) + image_btn.click(fn=run_image, inputs=[image_input] + origin_image + destination_image + thresholds_image + [face_mode_image, partial_reface_ratio_image], outputs=[image_output]) + image_input.change(fn=lambda filepath: extract_faces_auto(filepath, refacer, max_faces=num_faces), inputs=image_input, outputs=origin_image) + image_input.change(fn=lambda _: 0.0, inputs=image_input, outputs=partial_reface_ratio_image) # --- GIF MODE --- with gr.Tab("GIF Mode"): @@ -232,14 +210,10 @@ with gr.Blocks(theme=theme, title="NeoRefacer - AI Refacer") as demo: gif_file_output = gr.Image(label="Refaced GIF (GIF)", type="filepath") with gr.Row(): - face_mode_gif = gr.Radio( - choices=["Single Face", "Multiple Faces", "Faces By Match"], - value="Single Face", - label="Replacement Mode" - ) + face_mode_gif = gr.Radio(["Single Face", "Multiple Faces", "Faces By Match"], value="Single Face", label="Replacement Mode") + partial_reface_ratio_gif = gr.Slider(label="Reface Ratio (0 = Full Face, 0.5 = Half Face)", minimum=0.0, maximum=0.5, value=0.0, step=0.1) gif_btn = gr.Button("Reface GIF", variant="primary") - - preview_checkbox_gif = gr.Checkbox(label="Preview Generation (skip 90% of frames)", value=False) + preview_checkbox_gif = gr.Checkbox(label="Preview Generation (skip 90% of frames)", value=False) origin_gif, destination_gif, thresholds_gif, face_tabs_gif = [], [], [], [] @@ -254,34 +228,15 @@ with gr.Blocks(theme=theme, title="NeoRefacer - AI Refacer") as demo: thresholds_gif.append(threshold) face_tabs_gif.append(tab) - face_mode_gif.change( - fn=lambda mode: toggle_tabs_and_faces(mode, face_tabs_gif, origin_gif), - inputs=[face_mode_gif], - outputs=face_tabs_gif + origin_gif - ) + face_mode_gif.change(fn=lambda mode: toggle_tabs_and_faces(mode, face_tabs_gif, origin_gif), inputs=[face_mode_gif], outputs=face_tabs_gif + origin_gif) + demo.load(fn=lambda: toggle_tabs_and_faces("Single Face", face_tabs_gif, origin_gif), inputs=None, outputs=face_tabs_gif + origin_gif) - demo.load( - fn=lambda: toggle_tabs_and_faces("Single Face", face_tabs_gif, origin_gif), - inputs=None, - outputs=face_tabs_gif + origin_gif - ) + gif_btn.click(fn=run, inputs=[gif_input] + origin_gif + destination_gif + thresholds_gif + [preview_checkbox_gif, face_mode_gif, partial_reface_ratio_gif], outputs=[gif_output, gif_file_output]) - gif_btn.click( - fn=lambda *args: run(*args), - inputs=[gif_input] + origin_gif + destination_gif + thresholds_gif + [preview_checkbox_gif, face_mode_gif], - outputs=[gif_output, gif_file_output] - ) + gif_input.change(fn=lambda filepath: extract_faces_auto(filepath, refacer, max_faces=num_faces), inputs=gif_input, outputs=origin_gif) + gif_input.change(fn=lambda file: file, inputs=gif_input, outputs=[gif_preview]) + gif_input.change(fn=lambda _: 0.0, inputs=gif_input, outputs=partial_reface_ratio_gif) - gif_input.change( - fn=lambda filepath: extract_faces_auto(filepath, refacer, max_faces=num_faces), - inputs=gif_input, - outputs=origin_gif - ) - gif_input.change( - fn=lambda file: file, - inputs=gif_input, - outputs=[gif_preview] - ) # --- TIF MODE --- with gr.Tab("TIFF Mode"): @@ -297,6 +252,7 @@ with gr.Blocks(theme=theme, title="NeoRefacer - AI Refacer") as demo: value="Single Face", label="Replacement Mode" ) + partial_reface_ratio_tif = gr.Slider(label="Reface Ratio (0 = Full Face, 0.5 = Half Face)", minimum=0.0, maximum=0.5, value=0.0, step=0.1) tif_btn = gr.Button("Reface TIF", variant="primary") origin_tif, destination_tif, thresholds_tif, face_tabs_tif = [], [], [], [] @@ -343,7 +299,7 @@ with gr.Blocks(theme=theme, title="NeoRefacer - AI Refacer") as demo: tif_btn.click( fn=lambda tif_path, *args: process_tif(tif_path, *args), - inputs=[tif_input] + origin_tif + destination_tif + thresholds_tif + [face_mode_tif], + inputs=[tif_input] + origin_tif + destination_tif + thresholds_tif + [face_mode_tif, partial_reface_ratio_tif], outputs=[tif_preview, tif_output_preview, tif_output_file] ) @@ -354,14 +310,12 @@ with gr.Blocks(theme=theme, title="NeoRefacer - AI Refacer") as demo: ) tif_input.change( - fn=lambda filepath: ( - Image.open(filepath).convert('RGB').save( - (preview_path := os.path.join("./tmp", f"tif_preview_{int(time.time() * 1000)}.jpg")) - ) or preview_path - ), + fn=handle_tif_preview, inputs=tif_input, outputs=tif_preview ) + + tif_input.change(fn=lambda _: 0.0, inputs=tif_input, outputs=partial_reface_ratio_tif) # --- VIDEO MODE --- @@ -376,6 +330,7 @@ with gr.Blocks(theme=theme, title="NeoRefacer - AI Refacer") as demo: value="Single Face", label="Replacement Mode" ) + partial_reface_ratio_video = gr.Slider(label="Reface Ratio (0 = Full Face, 0.5 = Half Face)", minimum=0.0, maximum=0.5, value=0.0, step=0.1) video_btn = gr.Button("Reface Video", variant="primary") preview_checkbox_video = gr.Checkbox(label="Preview Generation (skip 90% of frames)", value=False) @@ -410,10 +365,12 @@ with gr.Blocks(theme=theme, title="NeoRefacer - AI Refacer") as demo: inputs=video_input, outputs=origin_video ) + + video_input.change(fn=lambda _: 0.0, inputs=video_input, outputs=partial_reface_ratio_video) video_btn.click( fn=lambda *args: run(*args), - inputs=[video_input] + origin_video + destination_video + thresholds_video + [preview_checkbox_video, face_mode_video], + inputs=[video_input] + origin_video + destination_video + thresholds_video + [preview_checkbox_video, face_mode_video, partial_reface_ratio_video], outputs=[video_output, gr.File(visible=False)] ) diff --git a/refacer.py b/refacer.py index 7777317..4952a1d 100644 --- a/refacer.py +++ b/refacer.py @@ -55,6 +55,46 @@ class Refacer: self.__check_providers() self.total_mem = psutil.virtual_memory().total self.__init_apps() + + def _partial_face_blend(self, original_frame, swapped_frame, face): + h_frame, w_frame = original_frame.shape[:2] + + x1, y1, x2, y2 = map(int, face.bbox) + x1 = max(0, min(x1, w_frame-1)) + y1 = max(0, min(y1, h_frame-1)) + x2 = max(0, min(x2, w_frame)) + y2 = max(0, min(y2, h_frame)) + + if x2 <= x1 or y2 <= y1: + print(f"Invalid bbox: {x1},{y1},{x2},{y2}") + return swapped_frame + + w = x2 - x1 + h = y2 - y1 + cutoff = int(h * (1.0 - self.blend_height_ratio)) + + swap_crop = swapped_frame[y1:y2, x1:x2].copy() + orig_crop = original_frame[y1:y2, x1:x2].copy() + + mask = np.ones((h, w, 3), dtype=np.float32) + transition = 40 + + if cutoff < h: + blend_start = max(cutoff - transition // 2, 0) + blend_end = min(cutoff + transition // 2, h) + + if blend_end > blend_start: + alpha = np.linspace(1.0, 0.0, blend_end - blend_start)[:, np.newaxis, np.newaxis] + mask[blend_start:blend_end, :, :] = alpha + mask[blend_end:, :, :] = 0.0 + + blended_crop = (swap_crop.astype(np.float32) * mask + orig_crop.astype(np.float32) * (1.0 - mask)).astype(np.uint8) + + blended_frame = swapped_frame.copy() + blended_frame[y1:y2, x1:x2] = blended_crop + + return blended_frame + def __download_with_progress(self, url, output_path): response = requests.get(url, stream=True) @@ -182,33 +222,53 @@ class Refacer: faces = self.__get_faces(frame, max_num=0) if not faces: return frame - + if self.disable_similarity: for face in faces: - frame = self.face_swapper.get(frame, face, self.replacement_faces[0][1], paste_back=True) + swapped = self.face_swapper.get(frame, face, self.replacement_faces[0][1], paste_back=True) + if hasattr(self, 'partial_reface_ratio') and self.partial_reface_ratio > 0.0: + self.blend_height_ratio = self.partial_reface_ratio + frame = self._partial_face_blend(frame, swapped, face) + else: + frame = swapped return frame def process_faces(self, frame): faces = self.__get_faces(frame, max_num=0) if not faces: return frame - - faces = sorted(faces, key=lambda face: face.bbox[0]) # Sort left to right - + + faces = sorted(faces, key=lambda face: face.bbox[0]) + if self.multiple_faces_mode: for idx, face in enumerate(faces): if idx >= len(self.replacement_faces): break - frame = self.face_swapper.get(frame, face, self.replacement_faces[idx][1], paste_back=True) + swapped = self.face_swapper.get(frame, face, self.replacement_faces[idx][1], paste_back=True) + if hasattr(self, 'partial_reface_ratio') and self.partial_reface_ratio > 0.0: + self.blend_height_ratio = self.partial_reface_ratio + frame = self._partial_face_blend(frame, swapped, face) + else: + frame = swapped elif self.disable_similarity: for face in faces: - frame = self.face_swapper.get(frame, face, self.replacement_faces[0][1], paste_back=True) + swapped = self.face_swapper.get(frame, face, self.replacement_faces[0][1], paste_back=True) + if hasattr(self, 'partial_reface_ratio') and self.partial_reface_ratio > 0.0: + self.blend_height_ratio = self.partial_reface_ratio + frame = self._partial_face_blend(frame, swapped, face) + else: + frame = swapped else: for rep_face in self.replacement_faces: for i in range(len(faces) - 1, -1, -1): sim = self.rec_app.compute_sim(rep_face[0], faces[i].embedding) if sim >= rep_face[2]: - frame = self.face_swapper.get(frame, faces[i], rep_face[1], paste_back=True) + swapped = self.face_swapper.get(frame, faces[i], rep_face[1], paste_back=True) + if hasattr(self, 'partial_reface_ratio') and self.partial_reface_ratio > 0.0: + self.blend_height_ratio = self.partial_reface_ratio + frame = self._partial_face_blend(frame, swapped, faces[i]) + else: + frame = swapped del faces[i] break return frame @@ -229,36 +289,37 @@ class Refacer: if audio_stream is not None: self.video_has_audio = True - def reface(self, video_path, faces, preview=False, disable_similarity=False, multiple_faces_mode=False): + def reface(self, video_path, faces, preview=False, disable_similarity=False, multiple_faces_mode=False, partial_reface_ratio=0.0): original_name = osp.splitext(osp.basename(video_path))[0] timestamp = str(int(time.time())) filename = f"{original_name}_preview.mp4" if preview else f"{original_name}_{timestamp}.mp4" - + self.__check_video_has_audio(video_path) - + if preview: os.makedirs("output/preview", exist_ok=True) output_video_path = os.path.join('output', 'preview', filename) else: os.makedirs("output", exist_ok=True) output_video_path = os.path.join('output', filename) - + self.prepare_faces(faces, disable_similarity=disable_similarity, multiple_faces_mode=multiple_faces_mode) self.first_face = False if multiple_faces_mode else (faces[0].get("origin") is None or disable_similarity) - + self.partial_reface_ratio = partial_reface_ratio + cap = cv2.VideoCapture(video_path, cv2.CAP_FFMPEG) total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) fps = cap.get(cv2.CAP_PROP_FPS) frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - + fourcc = cv2.VideoWriter_fourcc(*'mp4v') output = cv2.VideoWriter(output_video_path, fourcc, fps, (frame_width, frame_height)) - + frames = [] frame_index = 0 skip_rate = 10 if preview else 1 - + with tqdm(total=total_frames, desc="Extracting frames") as pbar: while cap.isOpened(): flag, frame = cap.read() @@ -272,24 +333,28 @@ class Refacer: gc.collect() frame_index += 1 pbar.update() - + cap.release() if frames: self.reface_group(faces, frames, output) output.release() - + converted_path = self.__convert_video(video_path, output_video_path, preview=preview) - + if video_path.lower().endswith(".gif"): if preview: gif_output_path = os.path.join("output", "preview", os.path.basename(converted_path).replace(".mp4", ".gif")) else: gif_output_path = os.path.join("output", "gifs", os.path.basename(converted_path).replace(".mp4", ".gif")) - + self.__generate_gif(converted_path, gif_output_path) return converted_path, gif_output_path - + return converted_path, None + + + + def __generate_gif(self, video_path, gif_output_path): os.makedirs(os.path.dirname(gif_output_path), exist_ok=True) @@ -314,61 +379,61 @@ class Refacer: print(f"Refaced video saved at: {os.path.abspath(new_path)}") return new_path - def reface_image(self, image_path, faces, disable_similarity=False, multiple_faces_mode=False): - self.prepare_faces(faces, disable_similarity=disable_similarity, multiple_faces_mode=multiple_faces_mode) - self.first_face = False if multiple_faces_mode else (faces[0].get("origin") is None or disable_similarity) + def reface_image(self, image_path, faces, disable_similarity=False, multiple_faces_mode=False, partial_reface_ratio=0.0): + self.prepare_faces(faces, disable_similarity=disable_similarity, multiple_faces_mode=multiple_faces_mode) + self.first_face = False if multiple_faces_mode else (faces[0].get("origin") is None or disable_similarity) + self.partial_reface_ratio = partial_reface_ratio + + ext = osp.splitext(image_path)[1].lower() + os.makedirs("output", exist_ok=True) + original_name = osp.splitext(osp.basename(image_path))[0] + timestamp = str(int(time.time())) + + if ext in ['.tif', '.tiff']: + pil_img = Image.open(image_path) + frames = [] + + page_count = 0 + try: + while True: + pil_img.seek(page_count) + page_count += 1 + except EOFError: + pass + + pil_img = Image.open(image_path) + + with tqdm(total=page_count, desc="Processing TIFF pages") as pbar: + for page in range(page_count): + pil_img.seek(page) + bgr_image = cv2.cvtColor(np.array(pil_img.convert('RGB')), cv2.COLOR_RGB2BGR) + refaced_bgr = self.process_first_face(bgr_image.copy()) if self.first_face else self.process_faces(bgr_image.copy()) + enhanced_bgr = enhance_image_memory(refaced_bgr) + enhanced_rgb = cv2.cvtColor(enhanced_bgr, cv2.COLOR_BGR2RGB) + enhanced_pil = Image.fromarray(enhanced_rgb) + frames.append(enhanced_pil) + pbar.update(1) + + output_path = os.path.join("output", f"{original_name}_{timestamp}.tif") + frames[0].save(output_path, save_all=True, append_images=frames[1:], compression="tiff_deflate") + print(f"Saved multipage refaced TIFF to {output_path}") + return output_path + + else: + bgr_image = cv2.imread(image_path) + if bgr_image is None: + raise ValueError("Failed to read input image") + + refaced_bgr = self.process_first_face(bgr_image.copy()) if self.first_face else self.process_faces(bgr_image.copy()) + refaced_rgb = cv2.cvtColor(refaced_bgr, cv2.COLOR_BGR2RGB) + pil_img = Image.fromarray(refaced_rgb) + filename = f"{original_name}_{timestamp}.jpg" + output_path = os.path.join("output", filename) + pil_img.save(output_path, format='JPEG', quality=100, subsampling=0) + output_path = enhance_image(output_path) + print(f"Saved refaced image to {output_path}") + return output_path - ext = osp.splitext(image_path)[1].lower() - os.makedirs("output", exist_ok=True) - original_name = osp.splitext(osp.basename(image_path))[0] - timestamp = str(int(time.time())) - - if ext in ['.tif', '.tiff']: - pil_img = Image.open(image_path) - frames = [] - - # First, count pages - page_count = 0 - try: - while True: - pil_img.seek(page_count) - page_count += 1 - except EOFError: - pass # End of pages - - # Re-open to start real processing - pil_img = Image.open(image_path) - - with tqdm(total=page_count, desc="Processing TIFF pages") as pbar: - for page in range(page_count): - pil_img.seek(page) - bgr_image = cv2.cvtColor(np.array(pil_img.convert('RGB')), cv2.COLOR_RGB2BGR) - refaced_bgr = self.process_first_face(bgr_image.copy()) if self.first_face else self.process_faces(bgr_image.copy()) - enhanced_bgr = enhance_image_memory(refaced_bgr) - enhanced_rgb = cv2.cvtColor(enhanced_bgr, cv2.COLOR_BGR2RGB) - enhanced_pil = Image.fromarray(enhanced_rgb) - frames.append(enhanced_pil) - pbar.update(1) - - output_path = os.path.join("output", f"{original_name}_{timestamp}.tif") - frames[0].save(output_path, save_all=True, append_images=frames[1:], compression="tiff_deflate") - print(f"Saved multipage refaced TIFF to {output_path}") - return output_path - - else: - bgr_image = cv2.imread(image_path) - if bgr_image is None: - raise ValueError("Failed to read input image") - - refaced_bgr = self.process_first_face(bgr_image.copy()) if self.first_face else self.process_faces(bgr_image.copy()) - refaced_rgb = cv2.cvtColor(refaced_bgr, cv2.COLOR_BGR2RGB) - pil_img = Image.fromarray(refaced_rgb) - filename = f"{original_name}_{timestamp}.jpg" - output_path = os.path.join("output", filename) - pil_img.save(output_path, format='JPEG', quality=100, subsampling=0) - output_path = enhance_image(output_path) - print(f"Saved refaced image to {output_path}") - return output_path def extract_faces_from_image(self, image_path, max_faces=5): frame = cv2.imread(image_path)