图像外扩 II - 差分扩散
这是关于图像外扩(outpainting)的第三篇指南,如果你想了解其他方法,可以看这里
在本指南中,我将深入探讨如何使用 差分扩散 (differential diffusion) 进行图像外扩,并详细介绍我为获得良好结果所采取的每一步。
我将从一张非正方形且具有景深(bokeh)效果的图片开始,以增加难度。当图片有这种背景时,拼接缝隙会非常明显。这张图片我从 Unsplash 上获取。
所以,第一个任务是把它变成一张正方形图片(扩展它),这样我们就可以继续把它变得更大。但我每次会生成 1024x1024 的图像,因为这是 SDXL 的最佳分辨率。
然后,我将测试仅使用灰色背景的新区域生成的效果。但要做到这一点,我们还需要创建一个可以与差分扩散配合使用的蒙版。为此,我将把边缘向左移动 50 像素,并应用一个模糊滤镜。这有助于平滑过渡。
处理成正方形的图像 | 蒙版 | 模糊处理的蒙版 |
---|---|---|
![]() |
![]() |
![]() |
我们将使用社区的 pipeline StableDiffusionXLDifferentialImg2ImgPipeline
,加载方式如下
pipeline = StableDiffusionXLPipeline.from_pretrained(
"stabilityai/stable-diffusion-xl-base-1.0",
torch_dtype=torch.float16,
variant="fp16",
custom_pipeline="pipeline_stable_diffusion_xl_differential_img2img",
).to("cuda")
image = pipeline(
prompt=prompt="",
negative_prompt="",
width=1024,
height=1024,
guidance_scale=6.0,
num_inference_steps=25,
original_image=image,
image=image,
strength=1.0,
map=mask,
).images[0]
此时,如果我们不带提示词生成图像,模型会认为灰色区域是一个灰色物体,比如一堵墙。
如果这是由会画画的人来使用,那个人可以画一个草图然后生成图像。但我不是那样的人,所以我需要为新的外扩区域想一个提示词。
为此,我们可以自己创建提示词,使用像 GPT-4V 或 Bing Chat 这样的在线聊天机器人,或者像 Llava 这样的本地视觉大语言模型 (VLLM)。我个人总是喜欢使用本地 VLLM,而这个模型引起了我的注意:internlm-xcomposer2-vl-7b-4bit,因为它即使只用 4-bit 版本也表现得非常好。
这是我得到的结果
这张图片捕捉到一个男人站在水边,可能是一个湖或一条河。他穿着一件印有“evolution”字样的白色连帽衫和卡其色裤子。肩上背着一个绿色背包,手里拿着一台相机。背景是晴空下的山脉。
作为对比,这是 Bing 给我的结果
这张图片描绘了一位摄影师,身穿户外装备,手持专业相机,背景是宁静的湖泊和白雪皑皑的群山,景色令人惊叹。这是人类活动与自然美景的美丽融合。
在进行图像修复或外扩时,提示词非常重要。举个例子,以下是使用这两个提示词的结果
对于这张特定的图片,或许对于 SDXL 来说普遍如此,XComposer2 生成的提示词更好,因为它描述了图片而没有使用像“令人惊叹的背景
”、“美丽的融合
”或“自然美景
”这样夸张的词语。
采用 XComposer2 的提示词并固定种子(seed),让我们看看差分扩散是如何工作的。
我们可以看到,差分扩散能更好地将外扩部分与原始图像融合在一起,即使它们完全不同。让我们看看增加模糊度会发生什么。
现在,我们可以清楚地看到为什么差分扩散是进行图像修复和外扩的一种非常好的方法。对于这个外扩区域,当模糊度为 80 或 100 时,我们能看到接缝的唯一原因就是颜色差异。请注意,模糊范围和区域越大,原始图像的变化就越大。
为了解决这个颜色问题,或者至少是减弱它,我们需要用其他东西填充新的区域。一些能帮助模型更好地理解我们想在新区域中得到什么的东西。
有几种技术可以做到这一点。它们各有帮助,但用途不同。例如,PatchMatch 或 LaMa 有助于图像修复(inpainting),因为它们会移除内容并用新的内容填充。对于这个用例,它们效果不太好,因为需要填充的区域太大且是全新的。所以,我将使用 OpenCV 的方法。在这种情况下,我喜欢使用 telea 算法
的结果。
要使用此方法,需要为 Python 安装 OpenCV
pip install opencv-python
在多个库之间转换图像不是一个好主意,因为这可能导致质量损失。所以,为此,我将所有函数都转换为使用 OpenCV
。唯一的主要区别是模糊。为了获得与 Pillow
类似的效果,我们需要使用一个大得多的值。在这种情况下,模糊半径为 500。
我们需要用于 Telea 修复的蒙版必须与原始蒙版大小相同,没有偏移,因为那才是我们想要替换的区域。
我们需要模型来使用这些信息。通常,对于修复模型或普通的图生图模型,我们会降低 strength
的值。但对于差分扩散,我们可以将这个值保持在最大,只需将蒙版变浅一些。我将为此使用深灰色。
现在,我们有了一些不错的结果,但我仍然看到两个问题。我们仍然能看到接缝,因为颜色有轻微的差异,而且我们依赖提示词来完成这个任务。如果我们传递了错误的提示词(如果你使用 VLLM,这很有可能发生),外扩的效果就会很差。
为了解决这两个问题,我们将使用 IP Adapter。这很明显;没有比图像提示(Image Prompt)更好的方式来告诉模型原始图像的细节了。
我们现在唯一的问题是原始图像不是正方形的,而 IP Adapter 只适用于正方形图像。原始作者提出了一个解决方案,涉及到调整图像大小和填充,但这会把这些信息提供给模型,而我们不希望这样,因为我们恰恰是想绘制那个区域。
既然我们并不真的需要给出一个确切的构图,并且我们可以给 IP Adapter 输入多张图片,那么我们要做的是将原始图像切成正方形,然后将这些正方形输入到 IP Adapter。为此,最好使用较大的初始图像,然后将每个正方形缩小到 224x224,这是它们需要的尺寸。
这个函数可以做到这一点
def slice_image(image):
height, width, _ = image.shape
slice_size = min(width // 2, height // 3)
slices = []
for h in range(3):
for w in range(2):
left = w * slice_size
upper = h * slice_size
right = left + slice_size
lower = upper + slice_size
if w == 1 and right > width:
left -= right - width
right = width
if h == 2 and lower > height:
upper -= lower - height
lower = height
slice = image[upper:lower, left:right]
slices.append(slice)
return slices
这些是我们用它得到的切片图像
在没有提示词的情况下,由于我们正在将这些图像输入到 IP Adapter,我们可以将 CFG 降至约 4.0
有时我们可能会得到仍有接缝的图像,但大多数时候它们都很好,而且我们解决了颜色差异问题,因为 IP Adapter 向模型提供了该信息。
现在我们有了一个可以扩展纵向/横向图像而无需提示词的脚本,这些是我用其他图像做的测试
原始 | 扩展后 |
---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
使用这种方法,如果你想保留的主体位于边界,它会有一点变化,因为我们使用的是模糊蒙版。如果你不希望这样,可以尝试减少蒙版的模糊度和偏移量。如果这不起作用,唯一的替代方法是使用修复模型。
还有一些图像用这种方法是行不通的。例如,这张
原始 | 扩展后 |
---|---|
![]() |
![]() |
那是因为我们只有主体的一半,而且 Telea 算法将颜色向右扩展了。在这种情况下,我们可以用提示词给它一点帮助。我将使用“colored eggs inside a round nest on a table”(桌上圆巢里的彩蛋)。
你使用的模型也非常重要。一些模型在图像外扩方面表现更好,而另一些则更适合写实照片或特定类型,如动漫、奇幻等。
现在,我们剩下的唯一事情就是创建真正大的外扩图
这是完整的代码。首先,我把图像变成正方形,然后进行扩展。你可以选择扩展的方向。请注意,这只是一个代码示例。你需要根据自己的需求进行修改,但希望这能帮助你开始使用 diffusers 和差分扩散进行这类图像外扩。
import random
import urllib.request
import cv2
import numpy as np
import torch
from diffusers import DPMSolverMultistepScheduler, StableDiffusionXLPipeline
def merge_images(original, new_image, offset, direction):
if direction in ["left", "right"]:
merged_image = np.zeros((original.shape[0], original.shape[1] + offset, 3), dtype=np.uint8)
elif direction in ["top", "bottom"]:
merged_image = np.zeros((original.shape[0] + offset, original.shape[1], 3), dtype=np.uint8)
if direction == "left":
merged_image[:, offset:] = original
merged_image[:, : new_image.shape[1]] = new_image
elif direction == "right":
merged_image[:, : original.shape[1]] = original
merged_image[:, original.shape[1] + offset - new_image.shape[1] : original.shape[1] + offset] = new_image
elif direction == "top":
merged_image[offset:, :] = original
merged_image[: new_image.shape[0], :] = new_image
elif direction == "bottom":
merged_image[: original.shape[0], :] = original
merged_image[original.shape[0] + offset - new_image.shape[0] : original.shape[0] + offset, :] = new_image
return merged_image
def slice_image(image):
height, width, _ = image.shape
slice_size = min(width // 2, height // 3)
slices = []
for h in range(3):
for w in range(2):
left = w * slice_size
upper = h * slice_size
right = left + slice_size
lower = upper + slice_size
if w == 1 and right > width:
left -= right - width
right = width
if h == 2 and lower > height:
upper -= lower - height
lower = height
slice = image[upper:lower, left:right]
slices.append(slice)
return slices
def process_image(
image,
fill_color=(0, 0, 0),
mask_offset=50,
blur_radius=500,
expand_pixels=256,
direction="left",
inpaint_mask_color=50,
max_size=1024,
):
height, width = image.shape[:2]
new_height = height + (expand_pixels if direction in ["top", "bottom"] else 0)
new_width = width + (expand_pixels if direction in ["left", "right"] else 0)
if new_height > max_size:
# If so, crop the image from the opposite side
if direction == "top":
image = image[:max_size, :]
elif direction == "bottom":
image = image[new_height - max_size :, :]
new_height = max_size
if new_width > max_size:
# If so, crop the image from the opposite side
if direction == "left":
image = image[:, :max_size]
elif direction == "right":
image = image[:, new_width - max_size :]
new_width = max_size
height, width = image.shape[:2]
new_image = np.full((new_height, new_width, 3), fill_color, dtype=np.uint8)
mask = np.full_like(new_image, 255, dtype=np.uint8)
inpaint_mask = np.full_like(new_image, 0, dtype=np.uint8)
mask = cv2.cvtColor(mask, cv2.COLOR_BGR2GRAY)
inpaint_mask = cv2.cvtColor(inpaint_mask, cv2.COLOR_BGR2GRAY)
if direction == "left":
new_image[:, expand_pixels:] = image[:, : max_size - expand_pixels]
mask[:, : expand_pixels + mask_offset] = inpaint_mask_color
inpaint_mask[:, :expand_pixels] = 255
elif direction == "right":
new_image[:, :width] = image
mask[:, width - mask_offset :] = inpaint_mask_color
inpaint_mask[:, width:] = 255
elif direction == "top":
new_image[expand_pixels:, :] = image[: max_size - expand_pixels, :]
mask[: expand_pixels + mask_offset, :] = inpaint_mask_color
inpaint_mask[:expand_pixels, :] = 255
elif direction == "bottom":
new_image[:height, :] = image
mask[height - mask_offset :, :] = inpaint_mask_color
inpaint_mask[height:, :] = 255
# mask blur
if blur_radius % 2 == 0:
blur_radius += 1
mask = cv2.GaussianBlur(mask, (blur_radius, blur_radius), 0)
# telea inpaint
_, mask_np = cv2.threshold(inpaint_mask, 128, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
inpaint = cv2.inpaint(new_image, mask_np, 3, cv2.INPAINT_TELEA)
# convert image to tensor
inpaint = cv2.cvtColor(inpaint, cv2.COLOR_BGR2RGB)
inpaint = torch.from_numpy(inpaint).permute(2, 0, 1).float()
inpaint = inpaint / 127.5 - 1
inpaint = inpaint.unsqueeze(0).to("cuda")
# convert mask to tensor
mask = torch.from_numpy(mask)
mask = mask.unsqueeze(0).float() / 255.0
mask = mask.to("cuda")
return inpaint, mask
def image_resize(image, new_size=1024):
height, width = image.shape[:2]
aspect_ratio = width / height
new_width = new_size
new_height = new_size
if aspect_ratio != 1:
if width > height:
new_height = int(new_size / aspect_ratio)
else:
new_width = int(new_size * aspect_ratio)
image = cv2.resize(image, (new_width, new_height), interpolation=cv2.INTER_LANCZOS4)
return image
pipeline = StableDiffusionXLPipeline.from_pretrained(
"SG161222/RealVisXL_V4.0",
torch_dtype=torch.float16,
variant="fp16",
custom_pipeline="pipeline_stable_diffusion_xl_differential_img2img",
).to("cuda")
pipeline.scheduler = DPMSolverMultistepScheduler.from_config(pipeline.scheduler.config, use_karras_sigmas=True)
pipeline.load_ip_adapter(
"h94/IP-Adapter",
subfolder="sdxl_models",
weight_name=[
"ip-adapter-plus_sdxl_vit-h.safetensors",
],
image_encoder_folder="models/image_encoder",
)
pipeline.set_ip_adapter_scale(0.1)
def generate_image(prompt, negative_prompt, image, mask, ip_adapter_image, seed: int = None):
if seed is None:
seed = random.randint(0, 2**32 - 1)
generator = torch.Generator(device="cpu").manual_seed(seed)
image = pipeline(
prompt=prompt,
negative_prompt=negative_prompt,
width=1024,
height=1024,
guidance_scale=4.0,
num_inference_steps=25,
original_image=image,
image=image,
strength=1.0,
map=mask,
generator=generator,
ip_adapter_image=[ip_adapter_image],
output_type="np",
).images[0]
image = (image * 255).astype(np.uint8)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
return image
prompt = ""
negative_prompt = ""
direction = "right" # left, right, top, bottom
inpaint_mask_color = 50 # lighter use more of the Telea inpainting
expand_pixels = 256 # I recommend to don't go more than half of the picture so it has context
times_to_expand = 4
url = "https://huggingface.co/datasets/OzzyGT/testing-resources/resolve/main/differential/photo-1711580377289-eecd23d00370.jpeg?download=true"
with urllib.request.urlopen(url) as url_response:
img_array = np.array(bytearray(url_response.read()), dtype=np.uint8)
original = cv2.imdecode(img_array, -1)
image = image_resize(original)
expand_pixels_to_square = 1024 - image.shape[1] # image.shape[1] for horizontal, image.shape[0] for vertical
image, mask = process_image(
image, expand_pixels=expand_pixels_to_square, direction=direction, inpaint_mask_color=inpaint_mask_color
)
ip_adapter_image = []
for index, part in enumerate(slice_image(original)):
ip_adapter_image.append(part)
generated = generate_image(prompt, negative_prompt, image, mask, ip_adapter_image)
final_image = generated
for i in range(times_to_expand):
image, mask = process_image(
final_image, direction=direction, expand_pixels=expand_pixels, inpaint_mask_color=inpaint_mask_color
)
ip_adapter_image = []
for index, part in enumerate(slice_image(generated)):
ip_adapter_image.append(part)
generated = generate_image(prompt, negative_prompt, image, mask, ip_adapter_image)
final_image = merge_images(final_image, generated, 256, direction)
cv2.imwrite("result.png", final_image)