2023-12-05 21:03:16 +00:00
|
|
|
import math
|
2023-11-17 23:36:28 +00:00
|
|
|
from typing import Optional
|
|
|
|
|
2023-12-05 21:03:16 +00:00
|
|
|
import cv2
|
2023-11-17 23:36:28 +00:00
|
|
|
import numpy as np
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
class TBLR(BaseModel):
|
|
|
|
top: int
|
|
|
|
bottom: int
|
|
|
|
left: int
|
|
|
|
right: int
|
|
|
|
|
2023-11-20 19:23:49 +00:00
|
|
|
def __eq__(self, other):
|
|
|
|
return (
|
|
|
|
self.top == other.top
|
|
|
|
and self.bottom == other.bottom
|
|
|
|
and self.left == other.left
|
|
|
|
and self.right == other.right
|
|
|
|
)
|
|
|
|
|
2023-11-17 23:36:28 +00:00
|
|
|
|
|
|
|
class Tile(BaseModel):
|
|
|
|
coords: TBLR = Field(description="The coordinates of this tile relative to its parent image.")
|
|
|
|
overlap: TBLR = Field(description="The amount of overlap with adjacent tiles on each side of this tile.")
|
|
|
|
|
2023-11-20 19:23:49 +00:00
|
|
|
def __eq__(self, other):
|
|
|
|
return self.coords == other.coords and self.overlap == other.overlap
|
|
|
|
|
2023-11-17 23:36:28 +00:00
|
|
|
|
|
|
|
def paste(dst_image: np.ndarray, src_image: np.ndarray, box: TBLR, mask: Optional[np.ndarray] = None):
|
|
|
|
"""Paste a source image into a destination image.
|
|
|
|
|
|
|
|
Args:
|
2023-12-09 18:38:07 +00:00
|
|
|
dst_image (np.array): The destination image to paste into. Shape: (H, W, C).
|
|
|
|
src_image (np.array): The source image to paste. Shape: (H, W, C). H and W must be compatible with 'box'.
|
2023-11-17 23:36:28 +00:00
|
|
|
box (TBLR): Box defining the region in the 'dst_image' where 'src_image' will be pasted.
|
2023-12-09 18:38:07 +00:00
|
|
|
mask (Optional[np.array]): A mask that defines the blending between 'src_image' and 'dst_image'.
|
2023-11-17 23:36:28 +00:00
|
|
|
Range: [0.0, 1.0], Shape: (H, W). The output is calculate per-pixel according to
|
|
|
|
`src * mask + dst * (1 - mask)`.
|
|
|
|
"""
|
|
|
|
|
|
|
|
if mask is None:
|
|
|
|
dst_image[box.top : box.bottom, box.left : box.right] = src_image
|
|
|
|
else:
|
|
|
|
mask = np.expand_dims(mask, -1)
|
|
|
|
dst_image_box = dst_image[box.top : box.bottom, box.left : box.right]
|
|
|
|
dst_image[box.top : box.bottom, box.left : box.right] = src_image * mask + dst_image_box * (1.0 - mask)
|
2023-12-05 21:03:16 +00:00
|
|
|
|
|
|
|
|
2023-12-06 08:10:22 +00:00
|
|
|
def seam_blend(ia1: np.ndarray, ia2: np.ndarray, blend_amount: int, x_seam: bool) -> np.ndarray:
|
2023-12-05 21:03:16 +00:00
|
|
|
"""Blend two overlapping tile sections using a seams to find a path.
|
|
|
|
|
|
|
|
It is assumed that input images will be RGB np arrays and are the same size.
|
|
|
|
|
|
|
|
Args:
|
2023-12-09 18:38:07 +00:00
|
|
|
ia1 (np.array): Image array 1 Shape: (H, W, C).
|
|
|
|
ia2 (np.array): Image array 2 Shape: (H, W, C).
|
2023-12-05 21:03:16 +00:00
|
|
|
x_seam (bool): If the images should be blended on the x axis or not.
|
|
|
|
blend_amount (int): The size of the blur to use on the seam. Half of this value will be used to avoid the edges of the image.
|
|
|
|
"""
|
|
|
|
assert ia1.shape == ia2.shape
|
|
|
|
assert ia2.size == ia2.size
|
|
|
|
|
|
|
|
def shift(arr, num, fill_value=255.0):
|
|
|
|
result = np.full_like(arr, fill_value)
|
|
|
|
if num > 0:
|
|
|
|
result[num:] = arr[:-num]
|
|
|
|
elif num < 0:
|
|
|
|
result[:num] = arr[-num:]
|
|
|
|
else:
|
|
|
|
result[:] = arr
|
|
|
|
return result
|
|
|
|
|
|
|
|
# Assume RGB and convert to grey
|
2023-12-11 16:55:27 +00:00
|
|
|
# Could offer other options for the luminance conversion
|
|
|
|
# BT.709 [0.2126, 0.7152, 0.0722], BT.2020 [0.2627, 0.6780, 0.0593])
|
|
|
|
# it might not have a huge impact due to the blur that is applied over the seam
|
2023-12-09 18:38:07 +00:00
|
|
|
iag1 = np.dot(ia1, [0.2989, 0.5870, 0.1140]) # BT.601 perceived brightness
|
2023-12-05 21:03:16 +00:00
|
|
|
iag2 = np.dot(ia2, [0.2989, 0.5870, 0.1140])
|
|
|
|
|
|
|
|
# Calc Difference between the images
|
|
|
|
ia = iag2 - iag1
|
|
|
|
|
|
|
|
# If the seam is on the X-axis rotate the array so we can treat it like a vertical seam
|
|
|
|
if x_seam:
|
|
|
|
ia = np.rot90(ia, 1)
|
|
|
|
|
|
|
|
# Calc max and min X & Y limits
|
|
|
|
# gutter is used to avoid the blur hitting the edge of the image
|
|
|
|
gutter = math.ceil(blend_amount / 2) if blend_amount > 0 else 0
|
|
|
|
max_y, max_x = ia.shape
|
|
|
|
max_x -= gutter
|
|
|
|
min_x = gutter
|
|
|
|
|
|
|
|
# Calc the energy in the difference
|
2023-12-11 16:55:27 +00:00
|
|
|
# Could offer different energy calculations e.g. Sobel or Scharr
|
2023-12-05 21:03:16 +00:00
|
|
|
energy = np.abs(np.gradient(ia, axis=0)) + np.abs(np.gradient(ia, axis=1))
|
|
|
|
|
2023-12-06 08:10:22 +00:00
|
|
|
# Find the starting position of the seam
|
2023-12-05 21:03:16 +00:00
|
|
|
res = np.copy(energy)
|
|
|
|
for y in range(1, max_y):
|
|
|
|
row = res[y, :]
|
|
|
|
rowl = shift(row, -1)
|
|
|
|
rowr = shift(row, 1)
|
|
|
|
res[y, :] = res[y - 1, :] + np.min([row, rowl, rowr], axis=0)
|
|
|
|
|
|
|
|
# create an array max_y long
|
|
|
|
lowest_energy_line = np.empty([max_y], dtype="uint16")
|
|
|
|
lowest_energy_line[max_y - 1] = np.argmin(res[max_y - 1, min_x : max_x - 1])
|
|
|
|
|
2023-12-06 08:10:22 +00:00
|
|
|
# Calc the path of the seam
|
2023-12-11 16:55:27 +00:00
|
|
|
# could offer options for larger search than just 1 pixel by adjusting lpos and rpos
|
2023-12-05 21:03:16 +00:00
|
|
|
for ypos in range(max_y - 2, -1, -1):
|
|
|
|
lowest_pos = lowest_energy_line[ypos + 1]
|
|
|
|
lpos = lowest_pos - 1
|
|
|
|
rpos = lowest_pos + 1
|
|
|
|
lpos = np.clip(lpos, min_x, max_x - 1)
|
|
|
|
rpos = np.clip(rpos, min_x, max_x - 1)
|
|
|
|
lowest_energy_line[ypos] = np.argmin(energy[ypos, lpos : rpos + 1]) + lpos
|
|
|
|
|
|
|
|
# Draw the mask
|
|
|
|
mask = np.zeros_like(ia)
|
|
|
|
for ypos in range(0, max_y):
|
|
|
|
to_fill = lowest_energy_line[ypos]
|
|
|
|
mask[ypos, :to_fill] = 1
|
|
|
|
|
|
|
|
# If the seam is on the X-axis rotate the array back
|
|
|
|
if x_seam:
|
|
|
|
mask = np.rot90(mask, 3)
|
|
|
|
|
|
|
|
# blur the seam mask if required
|
|
|
|
if blend_amount > 0:
|
|
|
|
mask = cv2.blur(mask, (blend_amount, blend_amount))
|
|
|
|
|
|
|
|
# for visual debugging
|
2023-12-06 08:10:22 +00:00
|
|
|
# from PIL import Image
|
|
|
|
# m_image = Image.fromarray((mask * 255.0).astype("uint8"))
|
2023-12-05 21:03:16 +00:00
|
|
|
|
|
|
|
# copy ia2 over ia1 while applying the seam mask
|
|
|
|
mask = np.expand_dims(mask, -1)
|
|
|
|
blended_image = ia1 * mask + ia2 * (1.0 - mask)
|
|
|
|
|
|
|
|
# for visual debugging
|
2023-12-06 08:10:22 +00:00
|
|
|
# i1 = Image.fromarray(ia1.astype("uint8"))
|
|
|
|
# i2 = Image.fromarray(ia2.astype("uint8"))
|
|
|
|
# b_image = Image.fromarray(blended_image.astype("uint8"))
|
|
|
|
# print(f"{ia1.shape}, {ia2.shape}, {mask.shape}, {blended_image.shape}")
|
|
|
|
# print(f"{i1.size}, {i2.size}, {m_image.size}, {b_image.size}")
|
2023-12-05 21:03:16 +00:00
|
|
|
|
|
|
|
return blended_image
|