Files
facefusion/facefusion/vision.py
T
Henry Ruhs 300470e7c7 port/master-into-v4 (#1176)
* 3.7.0 (#1175)

* mark as next, introduce dynamic scale for face debugger

* use latest onnxruntime

* update within Gradio 5

* Remove system memory limit (#986)

* remove system memory limit from ui

* remove system memory limit from args.py

* flatten the face store

* prevent countless importlib.import_module calls

* remove --onnxruntime from install.py

* remove --onnxruntime from install.py

* resolve static inference providers to fix macos (#1127)

* resolve static inference providers to fix macos

* fix lint

* restore old behaviour

* restore old behaviour

* handle ghost and uniface as well

* adjust condition for ghost and uniface

* fix Gradio gallery styles

* remove face store (#1132)

* fix dataflow in streamer

* Face selector auto mode (#1137)

* introduce face selector auto mode

* introduce face selector auto mode

* introduce face selector auto mode

* correct way is to pass source_vision_frames

* make the world a better place

* fix dataflow in faceswapper, no read of files withing inner methods (#1148)

* fix dataflow in faceswapper, no read of files withing inner methods

* fix lint

* adjust code more

* adjust code more

* bring back the face store but for source and reference only (#1149)

* bring back the face store but for source and reference only

* fix ci

* minor improvement

* guard for tobytes()

* drop condition in select_faces()

* Replace CONFIG_PARSER global with @lru_cache (#1147)

* remove global config_parser

* fix import order

* remove lambda

* remove unused block

* optimize app context detection

* decouple common modules from core (#1152)

* decouple common modules from core

* remove that nonsense

* remove that nonsense

* minor adjustment to workflows

* Tag HEVC output as hvc1 and move moov atom to the front (#1153)

* Tag HEVC output as hvc1 and move moov atom to the front

ffmpeg defaults HEVC in MP4 to the 'hev1' sample entry and leaves the moov
atom at the tail. Apple players (QuickTime, Finder QuickLook) refuse to decode
'hev1' and stall reading a tail-placed moov on large files, so hevc_nvenc /
libx265 renders cannot be previewed on macOS.

- add ffmpeg_builder.set_video_tag(): emit `-tag:v hvc1` for every HEVC
  encoder (libx265, hevc_nvenc, hevc_amf, hevc_qsv, hevc_videotoolbox).
  Applied in merge_video where the encoder is known; `-c:v copy` in the audio
  mux / concat steps preserves the tag.
- add ffmpeg_builder.set_faststart(): emit `-movflags +faststart`, applied in
  restore_audio / replace_audio / concat_video which write the final output.

H.264 and other codecs are left untouched. Verified on a real hevc_nvenc
render: hev1 hung QuickLook (no thumbnail); after the patch the file is hvc1
with a front-placed moov and QuickLook generates a thumbnail.

* Restrict hvc1 tag and faststart to quicktime containers

Gate set_video_tag / set_faststart on the output container format
(m4v, mov, mp4) via get_file_format(), so non-quicktime muxers no longer
receive -tag:v hvc1 / -movflags +faststart. Trim test_set_video_tag to a
single positive and negative assertion.

Addresses review on #1153.

* Move hvc1 tag and faststart gates into ffmpeg_builder

Rename set_video_tag / set_faststart to conditional_* and push the
container-format gate (m4v, mov, mp4) inside the builders, keeping
ffmpeg.py free of inline conditionals. Matches the set_image_quality
pattern. Addresses review on #1153.

* post cleanup after merge

* Pack target frames (#1158)

* pack target frames

* add todos

* add todos, resolve todos

* resolve todos

* change names

* revert to single target frame for select faces

* fix lint

* return empty frame

* get() have no default

* Fix trim (#1162)

* fix trim

* fix trim

* rename ffmpeg builder method

* rename to temp_frame_set and temp_frame_pattern

---------

Co-authored-by: harisreedhar <h4harisreedhar.s.s@gmail.com>
Co-authored-by: Harisreedhar <46858047+harisreedhar@users.noreply.github.com>

* Implement face tracker (#1163)

* add face tracker

* change get_nearest_track_face -> get_nearest_track_index

* create face_creator.py and move methods around

* add type FaceTrack

* naming

* remove iou test, don't belong there

* fix spaces

* rename to interpolate_points

* rename to find_best_face_track

* just track_faces

* cleanp

* previous next naming

* remove >= and >=

* rename

* remove helper from test and use face from source.jpg

* make get_anchor_indices more readable

* track_faces() call before and is forwarded to select_faces

* change to interpolate_faces

* rename methods

* rename methods

* rename variables

* remove dtype

* move face_anlyser -> face_creator

* claenup face_creator.py

* move tests to dedicated test face detector

* move tracking inside select_faces

* simplify face_tracker (#1165)

* minor renaming

* improve face_tracker test (#1166)

* improve face_tracker test

* cleanup

* Add target frame amount (#1167)

* introduce --target-frame-amount

* add ui

* make track_faces conditional

* update choices.py

* fix []

* rename component file to frame_process.py

* fix track preview (#1168)

* introduce face origin (#1169)

* add guard to prevent failure

* show and hide voice extractor according to lip syncer

* rename average_face_coordinates to average_face_geometry

* use static faces for select_faces()

* face store with lock (#1171)

* face store with lock

* face store with lock

* remove refill color from bbox

* adjust tests and handle frame_position proper way

* enforce similar naming

* introduce face tracker score

* introduce face tracker score

* fix/audio-trim-alignment (#1173)

* fix audio offset

* fix audio offset

* remove reference_frame_number check

---------

Co-authored-by: harisreedhar <h4harisreedhar.s.s@gmail.com>

* reduce face tracker score from 0 to 0.5

* mark as 3.7.0

* make face tracker stateless

---------

Co-authored-by: Harisreedhar <46858047+harisreedhar@users.noreply.github.com>
Co-authored-by: kazuki nakai <kazuki.nakai@agiletec.net>
Co-authored-by: harisreedhar <h4harisreedhar.s.s@gmail.com>

* update preview

* fix wording

* fix wording

* last minute change to frame distribution

---------

Co-authored-by: Harisreedhar <46858047+harisreedhar@users.noreply.github.com>
Co-authored-by: kazuki nakai <kazuki.nakai@agiletec.net>
Co-authored-by: harisreedhar <h4harisreedhar.s.s@gmail.com>
2026-07-01 11:51:54 +02:00

399 lines
15 KiB
Python

import math
from functools import lru_cache
from typing import Dict, List, Optional, Tuple
import cv2
import numpy
from cv2.typing import Size
from facefusion.common_helper import is_windows
from facefusion.filesystem import get_file_extension, is_image, is_video
from facefusion.media_helper import restrict_trim_frame
from facefusion.thread_helper import thread_lock, thread_semaphore
from facefusion.types import ColorMode, Duration, Fps, Mask, Orientation, Resolution, Scale, VisionFrame
from facefusion.video_manager import get_video_capture
def read_static_images(image_paths : List[str], color_mode : ColorMode = 'rgb') -> List[VisionFrame]:
vision_frames = []
if image_paths:
for image_path in image_paths:
vision_frames.append(read_static_image(image_path, color_mode))
return vision_frames
@lru_cache(maxsize = 64)
def read_static_image(image_path : str, color_mode : ColorMode = 'rgb') -> Optional[VisionFrame]:
return read_image(image_path, color_mode)
def read_image(image_path : str, color_mode : ColorMode = 'rgb') -> Optional[VisionFrame]:
if is_image(image_path):
flag = cv2.IMREAD_COLOR
if color_mode == 'rgba':
flag = cv2.IMREAD_UNCHANGED
if is_windows():
image_buffer = numpy.fromfile(image_path, dtype = numpy.uint8)
return cv2.imdecode(image_buffer, flag)
return cv2.imread(image_path, flag)
return None
def write_image(image_path : str, vision_frame : VisionFrame) -> bool:
if image_path:
if is_windows():
image_file_extension = get_file_extension(image_path)
_, vision_frame = cv2.imencode(image_file_extension, vision_frame)
vision_frame.tofile(image_path)
return is_image(image_path)
return cv2.imwrite(image_path, vision_frame)
return False
def detect_image_resolution(image_path : str) -> Optional[Resolution]:
if is_image(image_path):
image = read_image(image_path)
height, width = image.shape[:2]
if width > 0 and height > 0:
return width, height
return None
def restrict_image_resolution(image_path : str, resolution : Resolution) -> Resolution:
if is_image(image_path):
image_resolution = detect_image_resolution(image_path)
if image_resolution < resolution:
return image_resolution
return resolution
@lru_cache(maxsize = 64)
def read_static_video_frame(video_path : str, frame_number : int = 0) -> Optional[VisionFrame]:
return read_video_frame(video_path, frame_number)
def read_video_frame(video_path : str, frame_number : int = 0) -> Optional[VisionFrame]:
if is_video(video_path):
video_capture = get_video_capture(video_path)
if video_capture and video_capture.isOpened():
frame_total = video_capture.get(cv2.CAP_PROP_FRAME_COUNT)
frame_position = min(frame_total, frame_number)
with thread_semaphore():
video_capture.set(cv2.CAP_PROP_POS_FRAMES, frame_position)
has_vision_frame, vision_frame = video_capture.read()
if has_vision_frame:
return vision_frame
return None
@lru_cache(maxsize = 2)
def read_static_video_chunk(video_path : str, chunk_number : int, chunk_size : int) -> Dict[int, VisionFrame]:
return read_video_chunk(video_path, chunk_number, chunk_size)
def read_video_chunk(video_path : str, chunk_number : int, chunk_size : int) -> Dict[int, VisionFrame]:
video_frame_chunk = {}
if is_video(video_path) and chunk_number > -1:
video_capture = get_video_capture(video_path)
if video_capture and video_capture.isOpened():
frame_total = int(video_capture.get(cv2.CAP_PROP_FRAME_COUNT))
frame_position = chunk_number * chunk_size
with thread_semaphore():
video_capture.set(cv2.CAP_PROP_POS_FRAMES, frame_position)
for frame_number in range(frame_position, min(frame_position + chunk_size, frame_total)):
has_vision_frame, vision_frame = video_capture.read()
if has_vision_frame:
video_frame_chunk[frame_number] = vision_frame
return video_frame_chunk
def select_video_frames(video_path : str, frame_number : int = 0, frame_offset : int = 5) -> List[VisionFrame]:
vision_frames = []
chunk_size = (frame_offset * 2 + 1) * 4
if is_video(video_path):
with thread_lock():
for current_number in range(frame_number - frame_offset, frame_number + frame_offset + 1):
video_frame_chunk = read_static_video_chunk(video_path, current_number // chunk_size, chunk_size)
vision_frame = create_empty_vision_frame()
if current_number in video_frame_chunk:
vision_frame = video_frame_chunk.get(current_number)
vision_frames.append(vision_frame)
return vision_frames
def count_video_frame_total(video_path : str) -> int:
if is_video(video_path):
video_capture = get_video_capture(video_path)
if video_capture and video_capture.isOpened():
with thread_semaphore():
video_frame_total = int(video_capture.get(cv2.CAP_PROP_FRAME_COUNT))
return video_frame_total
return 0
def predict_video_frame_total(video_path : str, fps : Fps, trim_frame_start : int, trim_frame_end : int) -> int:
if is_video(video_path):
video_fps = detect_video_fps(video_path)
trim_frame_start, trim_frame_end = restrict_trim_video_frame(video_path, trim_frame_start, trim_frame_end)
extract_frame_total = (trim_frame_end - trim_frame_start) * fps / video_fps
return math.floor(extract_frame_total)
return 0
def detect_video_fps(video_path : str) -> Optional[Fps]:
if is_video(video_path):
video_capture = get_video_capture(video_path)
if video_capture and video_capture.isOpened():
with thread_semaphore():
video_fps = video_capture.get(cv2.CAP_PROP_FPS)
return video_fps
return None
def restrict_video_fps(video_path : str, fps : Fps) -> Fps:
if is_video(video_path):
video_fps = detect_video_fps(video_path)
if video_fps < fps:
return video_fps
return fps
def detect_video_duration(video_path : str) -> Duration:
video_frame_total = count_video_frame_total(video_path)
video_fps = detect_video_fps(video_path)
if video_frame_total and video_fps:
return video_frame_total / video_fps
return 0
def restrict_trim_video_frame(video_path : str, trim_frame_start : Optional[int], trim_frame_end : Optional[int]) -> Tuple[int, int]:
video_frame_total = count_video_frame_total(video_path)
return restrict_trim_frame(video_frame_total, trim_frame_start, trim_frame_end)
def detect_video_resolution(video_path : str) -> Optional[Resolution]:
if is_video(video_path):
video_capture = get_video_capture(video_path)
if video_capture and video_capture.isOpened():
with thread_semaphore():
width = video_capture.get(cv2.CAP_PROP_FRAME_WIDTH)
height = video_capture.get(cv2.CAP_PROP_FRAME_HEIGHT)
return int(width), int(height)
return None
def restrict_video_resolution(video_path : str, resolution : Resolution) -> Resolution:
if is_video(video_path):
video_resolution = detect_video_resolution(video_path)
if video_resolution < resolution:
return video_resolution
return resolution
def scale_resolution(resolution : Resolution, scale : Scale) -> Resolution:
resolution = (int(resolution[0] * scale), int(resolution[1] * scale))
resolution = normalize_resolution(resolution)
return resolution
def normalize_resolution(resolution : Tuple[float, float]) -> Resolution:
width, height = resolution
if width > 0 and height > 0:
normalize_width = round(width / 2) * 2
normalize_height = round(height / 2) * 2
return normalize_width, normalize_height
return 0, 0
def pack_resolution(resolution : Resolution) -> str:
width, height = normalize_resolution(resolution)
return str(width) + 'x' + str(height)
def unpack_resolution(resolution : str) -> Resolution:
width, height = map(int, resolution.split('x'))
return width, height
def detect_frame_orientation(vision_frame : VisionFrame) -> Orientation:
height, width = vision_frame.shape[:2]
if width > height:
return 'landscape'
return 'portrait'
def restrict_frame(vision_frame : VisionFrame, resolution : Resolution) -> VisionFrame:
height, width = vision_frame.shape[:2]
restrict_width, restrict_height = resolution
if height > restrict_height or width > restrict_width:
scale = min(restrict_height / height, restrict_width / width)
new_width = int(width * scale)
new_height = int(height * scale)
return cv2.resize(vision_frame, (new_width, new_height))
return vision_frame
def fit_contain_frame(vision_frame : VisionFrame, resolution : Resolution) -> VisionFrame:
contain_width, contain_height = resolution
height, width = vision_frame.shape[:2]
scale = min(contain_height / height, contain_width / width)
new_width = int(width * scale)
new_height = int(height * scale)
start_x = max(0, (contain_width - new_width) // 2)
start_y = max(0, (contain_height - new_height) // 2)
end_x = max(0, contain_width - new_width - start_x)
end_y = max(0, contain_height - new_height - start_y)
temp_vision_frame = cv2.resize(vision_frame, (new_width, new_height))
temp_vision_frame = numpy.pad(temp_vision_frame, ((start_y, end_y), (start_x, end_x), (0, 0)))
return temp_vision_frame
def fit_cover_frame(vision_frame : VisionFrame, resolution : Resolution) -> VisionFrame:
cover_width, cover_height = resolution
height, width = vision_frame.shape[:2]
scale = max(cover_width / width, cover_height / height)
new_width = int(width * scale)
new_height = int(height * scale)
start_x = max(0, (new_width - cover_width) // 2)
start_y = max(0, (new_height - cover_height) // 2)
end_x = min(new_width, start_x + cover_width)
end_y = min(new_height, start_y + cover_height)
temp_vision_frame = cv2.resize(vision_frame, (new_width, new_height))
temp_vision_frame = temp_vision_frame[start_y:end_y, start_x:end_x]
return temp_vision_frame
def obscure_frame(vision_frame : VisionFrame) -> VisionFrame:
return cv2.GaussianBlur(vision_frame, (99, 99), 0)
def blend_frame(source_vision_frame : VisionFrame, target_vision_frame : VisionFrame, blend_factor : float) -> VisionFrame:
blend_vision_frame = cv2.addWeighted(source_vision_frame, 1 - blend_factor, target_vision_frame, blend_factor, 0)
return blend_vision_frame
def conditional_match_frame_color(source_vision_frame : VisionFrame, target_vision_frame : VisionFrame) -> VisionFrame:
histogram_factor = calculate_histogram_difference(source_vision_frame, target_vision_frame)
target_vision_frame = blend_frame(target_vision_frame, match_frame_color(source_vision_frame, target_vision_frame), histogram_factor)
return target_vision_frame
def match_frame_color(source_vision_frame : VisionFrame, target_vision_frame : VisionFrame) -> VisionFrame:
color_difference_sizes = numpy.linspace(16, target_vision_frame.shape[0], 3, endpoint = False)
for color_difference_size in color_difference_sizes:
source_vision_frame = equalize_frame_color(source_vision_frame, target_vision_frame, normalize_resolution((color_difference_size, color_difference_size)))
target_vision_frame = equalize_frame_color(source_vision_frame, target_vision_frame, target_vision_frame.shape[:2][::-1])
return target_vision_frame
def equalize_frame_color(source_vision_frame : VisionFrame, target_vision_frame : VisionFrame, size : Size) -> VisionFrame:
source_frame_resize = cv2.resize(source_vision_frame, size, interpolation = cv2.INTER_AREA).astype(numpy.float32)
target_frame_resize = cv2.resize(target_vision_frame, size, interpolation = cv2.INTER_AREA).astype(numpy.float32)
color_difference_vision_frame = numpy.subtract(source_frame_resize, target_frame_resize)
color_difference_vision_frame = cv2.resize(color_difference_vision_frame, target_vision_frame.shape[:2][::-1], interpolation = cv2.INTER_CUBIC)
target_vision_frame = numpy.add(target_vision_frame, color_difference_vision_frame).clip(0, 255).astype(numpy.uint8)
return target_vision_frame
def calculate_histogram_difference(source_vision_frame : VisionFrame, target_vision_frame : VisionFrame) -> float:
histogram_source = cv2.calcHist([cv2.cvtColor(source_vision_frame, cv2.COLOR_BGR2HSV)], [ 0, 1 ], None, [ 50, 60 ], [ 0, 180, 0, 256 ])
histogram_target = cv2.calcHist([cv2.cvtColor(target_vision_frame, cv2.COLOR_BGR2HSV)], [ 0, 1 ], None, [ 50, 60 ], [ 0, 180, 0, 256 ])
histogram_difference = float(numpy.interp(cv2.compareHist(histogram_source, histogram_target, cv2.HISTCMP_CORREL), [ -1, 1 ], [ 0, 1 ]))
return histogram_difference
def blend_vision_frames(source_vision_frame : VisionFrame, target_vision_frame : VisionFrame, blend_factor : float) -> VisionFrame:
blend_vision_frame = cv2.addWeighted(source_vision_frame, 1 - blend_factor, target_vision_frame, blend_factor, 0)
return blend_vision_frame
def create_empty_vision_frame() -> VisionFrame:
return numpy.zeros((1, 1, 3)).astype(numpy.uint8)
def create_tile_frames(vision_frame : VisionFrame, size : Size) -> Tuple[List[VisionFrame], int, int]:
tile_width = size[0] - 2 * size[2]
pad_size_top = size[1] + size[2]
pad_size_bottom = pad_size_top + tile_width - (vision_frame.shape[0] + 2 * size[1]) % tile_width
pad_size_right = pad_size_top + tile_width - (vision_frame.shape[1] + 2 * size[1]) % tile_width
pad_vision_frame = numpy.pad(vision_frame, ((pad_size_top, pad_size_bottom), (pad_size_top, pad_size_right), (0, 0)))
pad_height, pad_width = pad_vision_frame.shape[:2]
row_range = range(size[2], pad_height - size[2], tile_width)
col_range = range(size[2], pad_width - size[2], tile_width)
tile_vision_frames = []
for row_vision_frame in row_range:
top = row_vision_frame - size[2]
bottom = row_vision_frame + size[2] + tile_width
for column_vision_frame in col_range:
left = column_vision_frame - size[2]
right = column_vision_frame + size[2] + tile_width
tile_vision_frames.append(pad_vision_frame[top:bottom, left:right, :])
return tile_vision_frames, pad_width, pad_height
def merge_tile_frames(tile_vision_frames : List[VisionFrame], temp_width : int, temp_height : int, pad_width : int, pad_height : int, size : Size) -> VisionFrame:
merge_vision_frame = numpy.zeros((pad_height, pad_width, 3)).astype(numpy.uint8)
tile_width = tile_vision_frames[0].shape[1] - 2 * size[2]
tiles_per_row = min(pad_width // tile_width, len(tile_vision_frames))
for index, tile_vision_frame in enumerate(tile_vision_frames):
tile_vision_frame = tile_vision_frame[size[2]:-size[2], size[2]:-size[2]]
row_index = index // tiles_per_row
col_index = index % tiles_per_row
top = row_index * tile_vision_frame.shape[0]
bottom = top + tile_vision_frame.shape[0]
left = col_index * tile_vision_frame.shape[1]
right = left + tile_vision_frame.shape[1]
merge_vision_frame[top:bottom, left:right, :] = tile_vision_frame
merge_vision_frame = merge_vision_frame[size[1] : size[1] + temp_height, size[1]: size[1] + temp_width, :]
return merge_vision_frame
def extract_vision_mask(vision_frame : VisionFrame) -> Mask:
if vision_frame.ndim == 3 and vision_frame.shape[2] == 4:
return vision_frame[:, :, 3]
return numpy.full(vision_frame.shape[:2], 255, dtype = numpy.uint8)
def merge_vision_mask(vision_frame : VisionFrame, vision_mask : Mask) -> VisionFrame:
return numpy.dstack((vision_frame[:, :, :3], vision_mask))
def conditional_merge_vision_mask(vision_frame : VisionFrame, vision_mask : Mask) -> VisionFrame:
if numpy.any(vision_mask < 255):
return merge_vision_mask(vision_frame, vision_mask)
return vision_frame