spot-the-diff / src /services /object_duplicator.py
RiceZero
v1.2
1d1bd8c
Raw History Blame Contribute Delete
7.58 kB
"""Object duplication with intelligent placement and blending."""
from __future__ import annotations
import random
import cv2
import numpy as np
from src.models.segment import Segment
class ObjectDuplicator:
"""Copies a segmented object to another location with intelligent placement."""
def duplicate(
self,
image: np.ndarray,
segment: Segment,
) -> tuple[np.ndarray, list[int] | None]:
"""Duplicate the segment's object to a new location.
Args:
image: BGR image (H, W, 3) uint8.
segment: The Segment to duplicate.
Returns:
(modified_image, new_bbox) or (original_image, None) on failure.
"""
placement = self._find_placement(image, segment)
if placement is None:
return image, None
dx, dy = placement
x1, y1, x2, y2 = segment.bbox
obj_h, obj_w = y2 - y1, x2 - x1
new_x1, new_y1 = x1 + dx, y1 + dy
new_x2, new_y2 = new_x1 + obj_w, new_y1 + obj_h
# Extract object pixels and its local mask
local_mask = segment.mask[y1:y2, x1:x2].astype(bool)
obj_pixels = image[y1:y2, x1:x2].copy()
result = image.copy()
# Paste with advanced alpha blending
target_region = result[new_y1:new_y2, new_x1:new_x2].copy()
# Create feathered mask with distance transform for better blending
mask_u8 = local_mask.astype(np.uint8) * 255
dist_transform = cv2.distanceTransform(mask_u8, cv2.DIST_L2, 3)
dist_transform = np.clip(dist_transform, 0, 5)
alpha = (dist_transform / 5.0).astype(np.float32)
# Apply Gaussian blur for smoother edges
alpha = cv2.GaussianBlur((alpha * 255).astype(np.uint8), (5, 5), 1.5)
alpha = alpha.astype(np.float32) / 255.0
alpha = alpha[:, :, np.newaxis]
# Color adaptation: slightly adjust object color to match target background
adapted_obj = self._adapt_colors(obj_pixels, target_region, local_mask)
# Blend with alpha
blended = (adapted_obj.astype(np.float32) * alpha +
target_region.astype(np.float32) * (1 - alpha))
result[new_y1:new_y2, new_x1:new_x2] = blended.astype(np.uint8)
# Post-process to reduce artifacts
result = self._post_process_addition(result, new_x1, new_y1, new_x2, new_y2, local_mask)
new_bbox = [new_x1, new_y1, new_x2, new_y2]
return result, new_bbox
def _find_placement(
self,
image: np.ndarray,
segment: Segment,
max_attempts: int = 30,
) -> tuple[int, int] | None:
"""Find a non-overlapping placement with similar background.
Returns None if no valid placement is found.
"""
h, w = image.shape[:2]
x1, y1, x2, y2 = segment.bbox
obj_w, obj_h = x2 - x1, y2 - y1
margin = 30 # minimum gap from original
# Sample background color around original object
orig_bg_color = self._sample_background_color(image, segment)
best_placement = None
best_score = float('inf')
for _ in range(max_attempts):
# Try random offset
dx = random.randint(-w // 2, w // 2)
dy = random.randint(-h // 2, h // 2)
# Skip if too close to original
if abs(dx) < obj_w + margin and abs(dy) < obj_h + margin:
continue
new_x1, new_y1 = x1 + dx, y1 + dy
new_x2, new_y2 = new_x1 + obj_w, new_y1 + obj_h
# Check bounds
if new_x1 < 0 or new_y1 < 0 or new_x2 > w or new_y2 > h:
continue
# Sample target background color
target_bg_color = self._sample_region_color(
image,
new_x1,
new_y1,
new_x2,
new_y2,
)
# Calculate color similarity (lower is better)
color_diff = np.linalg.norm(orig_bg_color - target_bg_color)
if color_diff < best_score:
best_score = color_diff
best_placement = (dx, dy)
return best_placement
def _sample_background_color(
self,
image: np.ndarray,
segment: Segment,
) -> np.ndarray:
"""Sample the average background color around a segment.
Args:
image: BGR image.
segment: Segment to sample around.
Returns:
Average BGR color as float array.
"""
x1, y1, x2, y2 = segment.bbox
local_mask = segment.mask[y1:y2, x1:x2]
# Invert mask to get background pixels
bg_mask = ~local_mask.astype(bool)
if np.count_nonzero(bg_mask) == 0:
return np.array([128, 128, 128], dtype=np.float32)
bg_pixels = image[y1:y2, x1:x2][bg_mask]
return np.mean(bg_pixels, axis=0).astype(np.float32)
def _sample_region_color(
self,
image: np.ndarray,
x1: int,
y1: int,
x2: int,
y2: int,
) -> np.ndarray:
"""Sample the average color of a region.
Args:
image: BGR image.
x1, y1, x2, y2: Region bounds.
Returns:
Average BGR color as float array.
"""
region = image[y1:y2, x1:x2]
return np.mean(region.reshape(-1, 3), axis=0).astype(np.float32)
def _adapt_colors(
self,
obj_pixels: np.ndarray,
target_bg: np.ndarray,
mask: np.ndarray,
) -> np.ndarray:
"""Adapt object colors to match target background lighting.
Args:
obj_pixels: Object pixel values.
target_bg: Target background pixels.
mask: Object mask.
Returns:
Color-adapted object pixels.
"""
# Calculate average brightness difference
obj_brightness = np.mean(cv2.cvtColor(obj_pixels, cv2.COLOR_BGR2GRAY))
bg_brightness = np.mean(cv2.cvtColor(target_bg, cv2.COLOR_BGR2GRAY))
brightness_ratio = bg_brightness / max(obj_brightness, 1)
# Limit adjustment to avoid overexposure
brightness_ratio = np.clip(brightness_ratio, 0.85, 1.15)
# Apply subtle brightness adjustment
adapted = obj_pixels.astype(np.float32) * brightness_ratio
adapted = np.clip(adapted, 0, 255).astype(np.uint8)
return adapted
def _post_process_addition(
self,
image: np.ndarray,
x1: int,
y1: int,
x2: int,
y2: int,
mask: np.ndarray,
) -> np.ndarray:
"""Apply post-processing to reduce artifacts around added object.
Args:
image: Image with added object.
x1, y1, x2, y2: Bounding box of added object.
mask: Local mask of object.
Returns:
Post-processed image.
"""
result = image.copy()
# Apply gentle bilateral filter to edge region
mask_u8 = mask.astype(np.uint8) * 255
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
dilated = cv2.dilate(mask_u8, kernel, iterations=2)
eroded = cv2.erode(mask_u8, kernel, iterations=1)
edge_mask = (dilated > 0) & (eroded == 0)
if np.count_nonzero(edge_mask) > 0:
region = result[y1:y2, x1:x2]
filtered = cv2.bilateralFilter(region, d=7, sigmaColor=50, sigmaSpace=50)
region[edge_mask] = filtered[edge_mask]
result[y1:y2, x1:x2] = region
return result