WANGP1 / wan /multitalk /multitalk_utils.py
rahul7star's picture
Migrated from GitHub
30f8a30 verified
import os
from einops import rearrange
import torch
import torch.nn as nn
from einops import rearrange, repeat
from functools import lru_cache
import imageio
import uuid
from tqdm import tqdm
import numpy as np
import subprocess
import soundfile as sf
import torchvision
import binascii
import os.path as osp
from skimage import color
VID_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv")
ASPECT_RATIO_627 = {
'0.26': ([320, 1216], 1), '0.38': ([384, 1024], 1), '0.50': ([448, 896], 1), '0.67': ([512, 768], 1),
'0.82': ([576, 704], 1), '1.00': ([640, 640], 1), '1.22': ([704, 576], 1), '1.50': ([768, 512], 1),
'1.86': ([832, 448], 1), '2.00': ([896, 448], 1), '2.50': ([960, 384], 1), '2.83': ([1088, 384], 1),
'3.60': ([1152, 320], 1), '3.80': ([1216, 320], 1), '4.00': ([1280, 320], 1)}
ASPECT_RATIO_960 = {
'0.22': ([448, 2048], 1), '0.29': ([512, 1792], 1), '0.36': ([576, 1600], 1), '0.45': ([640, 1408], 1),
'0.55': ([704, 1280], 1), '0.63': ([768, 1216], 1), '0.76': ([832, 1088], 1), '0.88': ([896, 1024], 1),
'1.00': ([960, 960], 1), '1.14': ([1024, 896], 1), '1.31': ([1088, 832], 1), '1.50': ([1152, 768], 1),
'1.58': ([1216, 768], 1), '1.82': ([1280, 704], 1), '1.91': ([1344, 704], 1), '2.20': ([1408, 640], 1),
'2.30': ([1472, 640], 1), '2.67': ([1536, 576], 1), '2.89': ([1664, 576], 1), '3.62': ([1856, 512], 1),
'3.75': ([1920, 512], 1)}
def torch_gc():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
def split_token_counts_and_frame_ids(T, token_frame, world_size, rank):
S = T * token_frame
split_sizes = [S // world_size + (1 if i < S % world_size else 0) for i in range(world_size)]
start = sum(split_sizes[:rank])
end = start + split_sizes[rank]
counts = [0] * T
for idx in range(start, end):
t = idx // token_frame
counts[t] += 1
counts_filtered = []
frame_ids = []
for t, c in enumerate(counts):
if c > 0:
counts_filtered.append(c)
frame_ids.append(t)
return counts_filtered, frame_ids
def normalize_and_scale(column, source_range, target_range, epsilon=1e-8):
source_min, source_max = source_range
new_min, new_max = target_range
normalized = (column - source_min) / (source_max - source_min + epsilon)
scaled = normalized * (new_max - new_min) + new_min
return scaled
# @torch.compile
def calculate_x_ref_attn_map(visual_q, ref_k, ref_target_masks, ref_images_count, mode='mean', attn_bias=None):
ref_k = ref_k.to(visual_q.dtype).to(visual_q.device)
scale = 1.0 / visual_q.shape[-1] ** 0.5
visual_q = visual_q * scale
visual_q = visual_q.transpose(1, 2)
ref_k = ref_k.transpose(1, 2)
attn = visual_q @ ref_k.transpose(-2, -1)
if attn_bias is not None: attn += attn_bias
x_ref_attn_map_source = attn.softmax(-1) # B, H, x_seqlens, ref_seqlens
x_ref_attn_maps = []
ref_target_masks = ref_target_masks.to(visual_q.dtype)
x_ref_attn_map_source = x_ref_attn_map_source.to(visual_q.dtype)
for class_idx, ref_target_mask in enumerate(ref_target_masks):
ref_target_mask = ref_target_mask[None, None, None, ...]
x_ref_attnmap = x_ref_attn_map_source * ref_target_mask
x_ref_attnmap = x_ref_attnmap.sum(-1) / ref_target_mask.sum() # B, H, x_seqlens, ref_seqlens --> B, H, x_seqlens
x_ref_attnmap = x_ref_attnmap.permute(0, 2, 1) # B, x_seqlens, H
if mode == 'mean':
x_ref_attnmap = x_ref_attnmap.mean(-1) # B, x_seqlens
elif mode == 'max':
x_ref_attnmap = x_ref_attnmap.max(-1) # B, x_seqlens
x_ref_attn_maps.append(x_ref_attnmap)
del attn
del x_ref_attn_map_source
return torch.concat(x_ref_attn_maps, dim=0)
def get_attn_map_with_target(visual_q, ref_k, shape, ref_target_masks=None, split_num=10, ref_images_count = 0):
"""Args:
query (torch.tensor): B M H K
key (torch.tensor): B M H K
shape (tuple): (N_t, N_h, N_w)
ref_target_masks: [B, N_h * N_w]
"""
N_t, N_h, N_w = shape
x_seqlens = N_h * N_w
ref_k = ref_k[:, :x_seqlens]
if ref_images_count > 0 :
visual_q_shape = visual_q.shape
visual_q = visual_q.reshape(visual_q_shape[0], N_t, -1)
visual_q = visual_q[:, ref_images_count:]
visual_q = visual_q.reshape(visual_q_shape[0], -1, *visual_q_shape[-2:])
_, seq_lens, heads, _ = visual_q.shape
class_num, _ = ref_target_masks.shape
x_ref_attn_maps = torch.zeros(class_num, seq_lens, dtype=visual_q.dtype, device=visual_q.device)
split_chunk = heads // split_num
for i in range(split_num):
x_ref_attn_maps_perhead = calculate_x_ref_attn_map(visual_q[:, :, i*split_chunk:(i+1)*split_chunk, :], ref_k[:, :, i*split_chunk:(i+1)*split_chunk, :], ref_target_masks, ref_images_count)
x_ref_attn_maps += x_ref_attn_maps_perhead
x_ref_attn_maps /= split_num
return x_ref_attn_maps
def rotate_half(x):
x = rearrange(x, "... (d r) -> ... d r", r=2)
x1, x2 = x.unbind(dim=-1)
x = torch.stack((-x2, x1), dim=-1)
return rearrange(x, "... d r -> ... (d r)")
class RotaryPositionalEmbedding1D(nn.Module):
def __init__(self,
head_dim,
):
super().__init__()
self.head_dim = head_dim
self.base = 10000
@lru_cache(maxsize=32)
def precompute_freqs_cis_1d(self, pos_indices):
freqs = 1.0 / (self.base ** (torch.arange(0, self.head_dim, 2)[: (self.head_dim // 2)].float() / self.head_dim))
freqs = freqs.to(pos_indices.device)
freqs = torch.einsum("..., f -> ... f", pos_indices.float(), freqs)
freqs = repeat(freqs, "... n -> ... (n r)", r=2)
return freqs
def forward(self, x, pos_indices):
"""1D RoPE.
Args:
query (torch.tensor): [B, head, seq, head_dim]
pos_indices (torch.tensor): [seq,]
Returns:
query with the same shape as input.
"""
freqs_cis = self.precompute_freqs_cis_1d(pos_indices)
x_ = x.float()
freqs_cis = freqs_cis.float().to(x.device)
cos, sin = freqs_cis.cos(), freqs_cis.sin()
cos, sin = rearrange(cos, 'n d -> 1 1 n d'), rearrange(sin, 'n d -> 1 1 n d')
x_ = (x_ * cos) + (rotate_half(x_) * sin)
return x_.type_as(x)
def rand_name(length=8, suffix=''):
name = binascii.b2a_hex(os.urandom(length)).decode('utf-8')
if suffix:
if not suffix.startswith('.'):
suffix = '.' + suffix
name += suffix
return name
def cache_video(tensor,
save_file=None,
fps=30,
suffix='.mp4',
nrow=8,
normalize=True,
value_range=(-1, 1),
retry=5):
# cache file
cache_file = osp.join('/tmp', rand_name(
suffix=suffix)) if save_file is None else save_file
# save to cache
error = None
for _ in range(retry):
# preprocess
tensor = tensor.clamp(min(value_range), max(value_range))
tensor = torch.stack([
torchvision.utils.make_grid(
u, nrow=nrow, normalize=normalize, value_range=value_range)
for u in tensor.unbind(2)
],
dim=1).permute(1, 2, 3, 0)
tensor = (tensor * 255).type(torch.uint8).cpu()
# write video
writer = imageio.get_writer(cache_file, fps=fps, codec='libx264', quality=10, ffmpeg_params=["-crf", "10"])
for frame in tensor.numpy():
writer.append_data(frame)
writer.close()
return cache_file
def save_video_ffmpeg(gen_video_samples, save_path, vocal_audio_list, fps=25, quality=5, high_quality_save=False):
def save_video(frames, save_path, fps, quality=9, ffmpeg_params=None):
writer = imageio.get_writer(
save_path, fps=fps, quality=quality, ffmpeg_params=ffmpeg_params
)
for frame in tqdm(frames, desc="Saving video"):
frame = np.array(frame)
writer.append_data(frame)
writer.close()
save_path_tmp = save_path + "-temp.mp4"
if high_quality_save:
cache_video(
tensor=gen_video_samples.unsqueeze(0),
save_file=save_path_tmp,
fps=fps,
nrow=1,
normalize=True,
value_range=(-1, 1)
)
else:
video_audio = (gen_video_samples+1)/2 # C T H W
video_audio = video_audio.permute(1, 2, 3, 0).cpu().numpy()
video_audio = np.clip(video_audio * 255, 0, 255).astype(np.uint8) # to [0, 255]
save_video(video_audio, save_path_tmp, fps=fps, quality=quality)
# crop audio according to video length
_, T, _, _ = gen_video_samples.shape
duration = T / fps
save_path_crop_audio = save_path + "-cropaudio.wav"
final_command = [
"ffmpeg",
"-i",
vocal_audio_list[0],
"-t",
f'{duration}',
save_path_crop_audio,
]
subprocess.run(final_command, check=True)
save_path = save_path + ".mp4"
if high_quality_save:
final_command = [
"ffmpeg",
"-y",
"-i", save_path_tmp,
"-i", save_path_crop_audio,
"-c:v", "libx264",
"-crf", "0",
"-preset", "veryslow",
"-c:a", "aac",
"-shortest",
save_path,
]
subprocess.run(final_command, check=True)
os.remove(save_path_tmp)
os.remove(save_path_crop_audio)
else:
final_command = [
"ffmpeg",
"-y",
"-i",
save_path_tmp,
"-i",
save_path_crop_audio,
"-c:v",
"libx264",
"-c:a",
"aac",
"-shortest",
save_path,
]
subprocess.run(final_command, check=True)
os.remove(save_path_tmp)
os.remove(save_path_crop_audio)
class MomentumBuffer:
def __init__(self, momentum: float):
self.momentum = momentum
self.running_average = 0
def update(self, update_value: torch.Tensor):
new_average = self.momentum * self.running_average
self.running_average = update_value + new_average
def project(
v0: torch.Tensor, # [B, C, T, H, W]
v1: torch.Tensor, # [B, C, T, H, W]
):
dtype = v0.dtype
v0, v1 = v0.double(), v1.double()
v1 = torch.nn.functional.normalize(v1, dim=[-1, -2, -3, -4])
v0_parallel = (v0 * v1).sum(dim=[-1, -2, -3, -4], keepdim=True) * v1
v0_orthogonal = v0 - v0_parallel
return v0_parallel.to(dtype), v0_orthogonal.to(dtype)
def adaptive_projected_guidance(
diff: torch.Tensor, # [B, C, T, H, W]
pred_cond: torch.Tensor, # [B, C, T, H, W]
momentum_buffer: MomentumBuffer = None,
eta: float = 0.0,
norm_threshold: float = 55,
):
if momentum_buffer is not None:
momentum_buffer.update(diff)
diff = momentum_buffer.running_average
if norm_threshold > 0:
ones = torch.ones_like(diff)
diff_norm = diff.norm(p=2, dim=[-1, -2, -3, -4], keepdim=True)
print(f"diff_norm: {diff_norm}")
scale_factor = torch.minimum(ones, norm_threshold / diff_norm)
diff = diff * scale_factor
diff_parallel, diff_orthogonal = project(diff, pred_cond)
normalized_update = diff_orthogonal + eta * diff_parallel
return normalized_update
def match_and_blend_colors(source_chunk: torch.Tensor, reference_image: torch.Tensor, strength: float) -> torch.Tensor:
"""
Matches the color of a source video chunk to a reference image and blends with the original.
Args:
source_chunk (torch.Tensor): The video chunk to be color-corrected (B, C, T, H, W) in range [-1, 1].
Assumes B=1 (batch size of 1).
reference_image (torch.Tensor): The reference image (B, C, 1, H, W) in range [-1, 1].
Assumes B=1 and T=1 (single reference frame).
strength (float): The strength of the color correction (0.0 to 1.0).
0.0 means no correction, 1.0 means full correction.
Returns:
torch.Tensor: The color-corrected and blended video chunk.
"""
# print(f"[match_and_blend_colors] Input source_chunk shape: {source_chunk.shape}, reference_image shape: {reference_image.shape}, strength: {strength}")
if strength == 0.0:
# print(f"[match_and_blend_colors] Strength is 0, returning original source_chunk.")
return source_chunk
if not 0.0 <= strength <= 1.0:
raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")
device = source_chunk.device
dtype = source_chunk.dtype
# Squeeze batch dimension, permute to T, H, W, C for skimage
# Source: (1, C, T, H, W) -> (T, H, W, C)
source_np = source_chunk.squeeze(0).permute(1, 2, 3, 0).cpu().numpy()
# Reference: (1, C, 1, H, W) -> (H, W, C)
ref_np = reference_image.squeeze(0).squeeze(1).permute(1, 2, 0).cpu().numpy() # Squeeze T dimension as well
# Normalize from [-1, 1] to [0, 1] for skimage
source_np_01 = (source_np + 1.0) / 2.0
ref_np_01 = (ref_np + 1.0) / 2.0
# Clip to ensure values are strictly in [0, 1] after potential float precision issues
source_np_01 = np.clip(source_np_01, 0.0, 1.0)
ref_np_01 = np.clip(ref_np_01, 0.0, 1.0)
# Convert reference to Lab
try:
ref_lab = color.rgb2lab(ref_np_01)
except ValueError as e:
# Handle potential errors if image data is not valid for conversion
print(f"Warning: Could not convert reference image to Lab: {e}. Skipping color correction for this chunk.")
return source_chunk
corrected_frames_np_01 = []
for i in range(source_np_01.shape[0]): # Iterate over time (T)
source_frame_rgb_01 = source_np_01[i]
try:
source_lab = color.rgb2lab(source_frame_rgb_01)
except ValueError as e:
print(f"Warning: Could not convert source frame {i} to Lab: {e}. Using original frame.")
corrected_frames_np_01.append(source_frame_rgb_01)
continue
corrected_lab_frame = source_lab.copy()
# Perform color transfer for L, a, b channels
for j in range(3): # L, a, b
mean_src, std_src = source_lab[:, :, j].mean(), source_lab[:, :, j].std()
mean_ref, std_ref = ref_lab[:, :, j].mean(), ref_lab[:, :, j].std()
# Avoid division by zero if std_src is 0
if std_src == 0:
# If source channel has no variation, keep it as is, but shift by reference mean
# This case is debatable, could also just copy source or target mean.
# Shifting by target mean helps if source is flat but target isn't.
corrected_lab_frame[:, :, j] = mean_ref
else:
corrected_lab_frame[:, :, j] = (corrected_lab_frame[:, :, j] - mean_src) * (std_ref / std_src) + mean_ref
try:
fully_corrected_frame_rgb_01 = color.lab2rgb(corrected_lab_frame)
except ValueError as e:
print(f"Warning: Could not convert corrected frame {i} back to RGB: {e}. Using original frame.")
corrected_frames_np_01.append(source_frame_rgb_01)
continue
# Clip again after lab2rgb as it can go slightly out of [0,1]
fully_corrected_frame_rgb_01 = np.clip(fully_corrected_frame_rgb_01, 0.0, 1.0)
# Blend with original source frame (in [0,1] RGB)
blended_frame_rgb_01 = (1 - strength) * source_frame_rgb_01 + strength * fully_corrected_frame_rgb_01
corrected_frames_np_01.append(blended_frame_rgb_01)
corrected_chunk_np_01 = np.stack(corrected_frames_np_01, axis=0)
# Convert back to [-1, 1]
corrected_chunk_np_minus1_1 = (corrected_chunk_np_01 * 2.0) - 1.0
# Permute back to (C, T, H, W), add batch dim, and convert to original torch.Tensor type and device
# (T, H, W, C) -> (C, T, H, W)
corrected_chunk_tensor = torch.from_numpy(corrected_chunk_np_minus1_1).permute(3, 0, 1, 2).unsqueeze(0)
corrected_chunk_tensor = corrected_chunk_tensor.contiguous() # Ensure contiguous memory layout
output_tensor = corrected_chunk_tensor.to(device=device, dtype=dtype)
# print(f"[match_and_blend_colors] Output tensor shape: {output_tensor.shape}")
return output_tensor
from skimage import color
from scipy import ndimage
from scipy.ndimage import binary_erosion, distance_transform_edt
def match_and_blend_colors_with_mask(
source_chunk: torch.Tensor,
reference_video: torch.Tensor,
mask: torch.Tensor,
strength: float,
copy_mode: str = "corrected", # "corrected", "reference", "source", "progressive_blend"
source_border_distance: int = 10,
reference_border_distance: int = 10
) -> torch.Tensor:
"""
Matches the color of a source video chunk to a reference video using mask-based region sampling.
Args:
source_chunk (torch.Tensor): The video chunk to be color-corrected (B, C, T, H, W) in range [-1, 1].
Assumes B=1 (batch size of 1).
reference_video (torch.Tensor): The reference video (B, C, T, H, W) in range [-1, 1].
Must have same temporal dimension as source_chunk.
mask (torch.Tensor): Binary mask (B, 1, T, H, W) or (T, H, W) or (H, W) with values 0 and 1.
Color correction is applied to pixels where mask=1.
strength (float): The strength of the color correction (0.0 to 1.0).
0.0 means no correction, 1.0 means full correction.
copy_mode (str): What to do with mask=0 pixels:
"corrected" (keep original), "reference", "source",
"progressive_blend" (double-sided progressive blending near borders).
source_border_distance (int): Distance in pixels from mask border to sample source video (mask=1 side).
reference_border_distance (int): Distance in pixels from mask border to sample reference video (mask=0 side).
For "progressive_blend" mode, this also defines the blending falloff distance.
Returns:
torch.Tensor: The color-corrected and blended video chunk.
Notes:
- Color statistics are sampled from border regions to determine source and reference tints
- Progressive blending creates smooth double-sided transitions:
* mask=1 side: 60% source + 40% reference at border → 100% source deeper in
* mask=0 side: 60% reference + 40% source at border → 100% reference deeper in
"""
if strength == 0.0:
return source_chunk
if not 0.0 <= strength <= 1.0:
raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")
if copy_mode not in ["corrected", "reference", "source", "progressive_blend"]:
raise ValueError(f"copy_mode must be 'corrected', 'reference', 'source', or 'progressive_blend', got {copy_mode}")
device = source_chunk.device
dtype = source_chunk.dtype
B, C, T, H, W = source_chunk.shape
# Handle different mask dimensions
if mask.dim() == 2: # (H, W)
mask = mask.unsqueeze(0).unsqueeze(0).unsqueeze(0).expand(B, 1, T, H, W)
elif mask.dim() == 3: # (T, H, W)
mask = mask.unsqueeze(0).unsqueeze(0).expand(B, 1, T, H, W)
elif mask.dim() == 4: # (B, T, H, W) - missing channel dim
mask = mask.unsqueeze(1)
# mask should now be (B, 1, T, H, W)
# Convert to numpy for processing
source_np = source_chunk.squeeze(0).permute(1, 2, 3, 0).cpu().numpy() # (T, H, W, C)
reference_np = reference_video.squeeze(0).permute(1, 2, 3, 0).cpu().numpy() # (T, H, W, C)
mask_np = mask.squeeze(0).squeeze(0).cpu().numpy() # (T, H, W)
# Normalize from [-1, 1] to [0, 1] for skimage
source_np_01 = (source_np + 1.0) / 2.0
reference_np_01 = (reference_np + 1.0) / 2.0
# Clip to ensure values are in [0, 1]
source_np_01 = np.clip(source_np_01, 0.0, 1.0)
reference_np_01 = np.clip(reference_np_01, 0.0, 1.0)
corrected_frames_np_01 = []
for t in range(T):
source_frame = source_np_01[t] # (H, W, C)
reference_frame = reference_np_01[t] # (H, W, C)
frame_mask = mask_np[t] # (H, W)
# Find mask borders and create distance maps
border_regions = get_border_sampling_regions(frame_mask, source_border_distance, reference_border_distance)
source_sample_region = border_regions['source_region'] # mask=1 side
reference_sample_region = border_regions['reference_region'] # mask=0 side
# Sample pixels for color statistics
try:
source_stats = compute_color_stats(source_frame, source_sample_region)
reference_stats = compute_color_stats(reference_frame, reference_sample_region)
except ValueError as e:
print(f"Warning: Could not compute color statistics for frame {t}: {e}. Using original frame.")
corrected_frames_np_01.append(source_frame)
continue
# Apply color correction to mask=1 area and handle mask=0 area based on copy_mode
corrected_frame = apply_color_correction_with_mask(
source_frame, frame_mask, source_stats, reference_stats, strength
)
# Handle mask=0 pixels based on copy_mode
if copy_mode == "reference":
corrected_frame = apply_copy_with_mask(corrected_frame, reference_frame, frame_mask, "reference")
elif copy_mode == "source":
corrected_frame = apply_copy_with_mask(corrected_frame, source_frame, frame_mask, "source")
elif copy_mode == "progressive_blend":
# Apply progressive blending in mask=1 border area (source side)
corrected_frame = apply_progressive_blend_in_corrected_area(
corrected_frame, reference_frame, frame_mask,
border_regions['source_region'], border_regions['source_distances'],
border_regions['reference_region'], source_border_distance
)
# Copy reference pixels to mask=0 area first
corrected_frame = apply_copy_with_mask(corrected_frame, reference_frame, frame_mask, "reference")
# Then apply progressive blending in mask=0 border area (reference side)
corrected_frame = apply_progressive_blend_in_reference_area(
corrected_frame, source_frame, frame_mask,
border_regions['reference_region'], border_regions['reference_distances'],
reference_border_distance
)
corrected_frames_np_01.append(corrected_frame)
corrected_chunk_np_01 = np.stack(corrected_frames_np_01, axis=0)
# Convert back to [-1, 1] and return to tensor format
corrected_chunk_np_minus1_1 = (corrected_chunk_np_01 * 2.0) - 1.0
corrected_chunk_tensor = torch.from_numpy(corrected_chunk_np_minus1_1).permute(3, 0, 1, 2).unsqueeze(0)
corrected_chunk_tensor = corrected_chunk_tensor.contiguous()
output_tensor = corrected_chunk_tensor.to(device=device, dtype=dtype)
return output_tensor
def get_border_sampling_regions(mask, source_border_distance, reference_border_distance):
"""
Create regions for sampling near mask borders with separate distances for source and reference.
Args:
mask: Binary mask (H, W) with 0s and 1s
source_border_distance: Distance from border to include in source sampling (mask=1 side)
reference_border_distance: Distance from border to include in reference sampling (mask=0 side)
Returns:
Dict with sampling regions and distance maps for blending
"""
# Convert to boolean for safety
mask_bool = mask.astype(bool)
# Distance from mask=0 regions (distance into mask=1 areas from border)
dist_from_mask0 = distance_transform_edt(mask_bool)
# Distance from mask=1 regions (distance into mask=0 areas from border)
dist_from_mask1 = distance_transform_edt(~mask_bool)
# Source region: mask=1 pixels within source_border_distance of mask=0 pixels
source_region = mask_bool & (dist_from_mask0 <= source_border_distance)
# Reference region: mask=0 pixels within reference_border_distance of mask=1 pixels
reference_region = (~mask_bool) & (dist_from_mask1 <= reference_border_distance)
return {
'source_region': source_region,
'reference_region': reference_region,
'source_distances': dist_from_mask0, # Distance into mask=1 from border
'reference_distances': dist_from_mask1 # Distance into mask=0 from border
}
def compute_color_stats(image, sample_region):
"""
Compute color statistics (mean and std) for Lab channels in the sampling region.
Args:
image: RGB image (H, W, C) in range [0, 1]
sample_region: Boolean mask (H, W) indicating pixels to sample
Returns:
Dict with 'mean' and 'std' for Lab components
"""
if not np.any(sample_region):
raise ValueError("No pixels in sampling region")
# Convert to Lab
try:
image_lab = color.rgb2lab(image)
except ValueError as e:
raise ValueError(f"Could not convert image to Lab: {e}")
# Extract pixels in sampling region
sampled_pixels = image_lab[sample_region] # (N, 3) where N is number of sampled pixels
# Compute statistics for each Lab channel
stats = {
'mean': np.mean(sampled_pixels, axis=0), # (3,) for L, a, b
'std': np.std(sampled_pixels, axis=0) # (3,) for L, a, b
}
return stats
def apply_color_correction_with_mask(source_frame, mask, source_stats, reference_stats, strength):
"""
Apply color correction to pixels where mask=1.
Args:
source_frame: RGB image (H, W, C) in range [0, 1]
mask: Binary mask (H, W)
source_stats: Color statistics from source sampling region
reference_stats: Color statistics from reference sampling region
strength: Blending strength
Returns:
Corrected RGB image (H, W, C)
"""
try:
source_lab = color.rgb2lab(source_frame)
except ValueError as e:
print(f"Warning: Could not convert source frame to Lab: {e}. Using original frame.")
return source_frame
corrected_lab = source_lab.copy()
correction_region = (mask == 1) # Apply correction to mask=1 pixels
# Apply color transfer to pixels where mask=1
for c in range(3): # L, a, b channels
mean_src = source_stats['mean'][c]
std_src = source_stats['std'][c]
mean_ref = reference_stats['mean'][c]
std_ref = reference_stats['std'][c]
if std_src == 0:
# Handle case where source channel has no variation
corrected_lab[correction_region, c] = mean_ref
else:
# Standard color transfer formula
corrected_lab[correction_region, c] = (
(corrected_lab[correction_region, c] - mean_src) * (std_ref / std_src) + mean_ref
)
try:
fully_corrected_rgb = color.lab2rgb(corrected_lab)
except ValueError as e:
print(f"Warning: Could not convert corrected frame back to RGB: {e}. Using original frame.")
return source_frame
# Clip to [0, 1]
fully_corrected_rgb = np.clip(fully_corrected_rgb, 0.0, 1.0)
# Blend with original (only in correction region)
result = source_frame.copy()
result[correction_region] = (
(1 - strength) * source_frame[correction_region] +
strength * fully_corrected_rgb[correction_region]
)
return result
def apply_progressive_blend_in_corrected_area(corrected_frame, reference_frame, mask, source_region, source_distances, reference_region, source_border_distance):
"""
Apply progressive blending in the corrected area (mask=1) near the border.
Args:
corrected_frame: RGB image (H, W, C) - the color-corrected source frame
reference_frame: RGB image (H, W, C) - the reference frame
mask: Binary mask (H, W)
source_region: Boolean mask (H, W) indicating the source blending region (mask=1 near border)
source_distances: Distance map (H, W) into mask=1 area from mask=0 border
reference_region: Boolean mask (H, W) indicating the reference sampling region (mask=0 near border)
source_border_distance: Maximum distance for source blending
Returns:
Blended RGB image (H, W, C)
Notes:
- Each source pixel blends with its closest reference border pixel (for speed)
- At mask border: 60% source + 40% reference
- Deeper into mask=1 area: 100% corrected source
"""
result = corrected_frame.copy()
# Blend in the source region (mask=1 pixels near border)
blend_region = source_region
if np.any(blend_region):
# Find immediate border pixels (mask=0 pixels adjacent to mask=1 pixels)
# This is much faster than using the entire reference region
from scipy.ndimage import binary_dilation
# Dilate mask=1 by 1 pixel, then find intersection with mask=0
mask_1_dilated = binary_dilation(mask == 1, structure=np.ones((3, 3)))
border_pixels = (mask == 0) & mask_1_dilated
if np.any(border_pixels):
# Find closest border pixel for each source pixel
source_coords = np.column_stack(np.where(blend_region)) # (N, 2) - y, x coordinates
border_coords = np.column_stack(np.where(border_pixels)) # (M, 2) - much smaller set!
# For each source pixel, find closest border pixel
from scipy.spatial.distance import cdist
distances_matrix = cdist(source_coords, border_coords, metric='euclidean')
closest_border_indices = np.argmin(distances_matrix, axis=1)
# Normalize source distances for blending weights
min_distance_in_region = np.min(source_distances[blend_region])
max_distance_in_region = np.max(source_distances[blend_region])
if max_distance_in_region > min_distance_in_region:
# Calculate blend weights: 0.4 at border (60% source + 40% reference), 0.0 at max distance (100% source)
source_dist_values = source_distances[blend_region]
normalized_distances = (source_dist_values - min_distance_in_region) / (max_distance_in_region - min_distance_in_region)
blend_weights = 0.4 * (1.0 - normalized_distances) # Start with 40% reference influence at border
# Apply blending with closest border pixels
for i, (source_y, source_x) in enumerate(source_coords):
closest_border_idx = closest_border_indices[i]
border_y, border_x = border_coords[closest_border_idx]
weight = blend_weights[i]
# Blend with closest border pixel
result[source_y, source_x] = (
(1.0 - weight) * corrected_frame[source_y, source_x] +
weight * reference_frame[border_y, border_x]
)
return result
def apply_progressive_blend_in_reference_area(reference_frame, source_frame, mask, reference_region, reference_distances, reference_border_distance):
"""
Apply progressive blending in the reference area (mask=0) near the border.
Args:
reference_frame: RGB image (H, W, C) - the reference frame with copied reference pixels
source_frame: RGB image (H, W, C) - the original source frame
mask: Binary mask (H, W)
reference_region: Boolean mask (H, W) indicating the reference blending region (mask=0 near border)
reference_distances: Distance map (H, W) into mask=0 area from mask=1 border
reference_border_distance: Maximum distance for reference blending
Returns:
Blended RGB image (H, W, C)
Notes:
- Each reference pixel blends with its closest source border pixel (for speed)
- At mask border: 60% reference + 40% source
- Deeper into mask=0 area: 100% reference
"""
result = reference_frame.copy()
# Blend in the reference region (mask=0 pixels near border)
blend_region = reference_region
if np.any(blend_region):
# Find immediate border pixels (mask=1 pixels adjacent to mask=0 pixels)
from scipy.ndimage import binary_dilation
# Dilate mask=0 by 1 pixel, then find intersection with mask=1
mask_0_dilated = binary_dilation(mask == 0, structure=np.ones((3, 3)))
source_border_pixels = (mask == 1) & mask_0_dilated
if np.any(source_border_pixels):
# Find closest source border pixel for each reference pixel
reference_coords = np.column_stack(np.where(blend_region)) # (N, 2) - y, x coordinates
source_border_coords = np.column_stack(np.where(source_border_pixels)) # (M, 2)
# For each reference pixel, find closest source border pixel
from scipy.spatial.distance import cdist
distances_matrix = cdist(reference_coords, source_border_coords, metric='euclidean')
closest_source_indices = np.argmin(distances_matrix, axis=1)
# Normalize reference distances for blending weights
min_distance_in_region = np.min(reference_distances[blend_region])
max_distance_in_region = np.max(reference_distances[blend_region])
if max_distance_in_region > min_distance_in_region:
# Calculate blend weights: 0.4 at border (60% reference + 40% source), 0.0 at max distance (100% reference)
reference_dist_values = reference_distances[blend_region]
normalized_distances = (reference_dist_values - min_distance_in_region) / (max_distance_in_region - min_distance_in_region)
blend_weights = 0.4 * (1.0 - normalized_distances) # Start with 40% source influence at border
# Apply blending with closest source border pixels
for i, (ref_y, ref_x) in enumerate(reference_coords):
closest_source_idx = closest_source_indices[i]
source_y, source_x = source_border_coords[closest_source_idx]
weight = blend_weights[i]
# Blend: weight=0.4 means 60% reference + 40% source at border
result[ref_y, ref_x] = (
(1.0 - weight) * reference_frame[ref_y, ref_x] +
weight * source_frame[source_y, source_x]
)
return result
def apply_copy_with_mask(source_frame, reference_frame, mask, copy_source):
"""
Copy pixels to mask=0 regions based on copy_source parameter.
Args:
source_frame: RGB image (H, W, C)
reference_frame: RGB image (H, W, C)
mask: Binary mask (H, W)
copy_source: "reference" or "source"
Returns:
Combined RGB image (H, W, C)
"""
result = source_frame.copy()
mask_0_region = (mask == 0)
if copy_source == "reference":
result[mask_0_region] = reference_frame[mask_0_region]
# If "source", we keep the original source pixels (no change needed)
return result