-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathimage_offset.py
More file actions
223 lines (172 loc) · 9.07 KB
/
Copy pathimage_offset.py
File metadata and controls
223 lines (172 loc) · 9.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# ==========================================================================
# Eses Image Offset
# ==========================================================================
#
# Description:
# The 'Eses Image Offset' node offers basic image
# manipulation including offsetting capabilities within ComfyUI.
# It allows shifting image and mask content horizontally and/or vertically,
# with an option to wrap content around the canvas edges for a tiling effect.
#
# This node takes an image and an an optional mask as input, applies the
# specified offset, and outputs the processed image, mask, the
# applied X and Y offsets, and a descriptive info string.
#
# Version: 1.1.1
#
# License: See LICENSE.txt
#
# ==========================================================================
import torch
from PIL import Image, ImageOps, ImageChops
import numpy as np
# Define step values as variables
SMALL_STEP = 1
class EsesImageOffset:
"""
Applies offset (translation) transformations to an image and an
optional mask. It also supports wrapping content for tiling effects and
provides control over fill color for newly exposed areas.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"offset_x": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": SMALL_STEP}),
"offset_y": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": SMALL_STEP}),
"wrap_around": (["Off", "On"],),
"fill_color": ("STRING", {"default": "0,0,0"}),
"invert_mask_output": (["No", "Yes"],),
},
"optional": {
"image": ("IMAGE",),
"mask": ("MASK",),
},
"hidden": {
"__output_info__": ("STRING", {"_print_": True}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING",)
RETURN_NAMES = ("IMAGE", "MASK", "offset_x", "offset_y", "info",)
FUNCTION = "apply_image_transformations"
CATEGORY = "Eses Nodes/Image"
def _parse_color_string(self, color_string):
color_string = color_string.strip()
if not color_string: return (0, 0, 0, 0)
try:
if len(color_string) == 6: return tuple(int(color_string[i:i+2], 16) for i in (0, 2, 4)) + (255,)
if len(color_string) == 8: return tuple(int(color_string[i:i+2], 16) for i in (0, 2, 4, 6))
except ValueError: pass
try:
parts = [int(p.strip()) for p in color_string.split(',')]
if len(parts) == 3: return tuple(parts) + (255,) # Default to opaque if only RGB is provided
if len(parts) == 4: return tuple(parts)
except ValueError: pass
return (0, 0, 0, 0)
def apply_image_transformations(self, offset_x, offset_y, wrap_around, fill_color, invert_mask_output, image=None, mask=None):
if image is None and mask is None:
# Handle no input
dummy_image = torch.zeros(1, 64, 64, 3)
dummy_mask = torch.zeros(1, 64, 64)
return (dummy_image, dummy_mask, offset_x, offset_y, "No Image or Mask Input")
is_mask_connected = mask is not None
img_pil, mask_pil = None, None
current_width, current_height = 0, 0
# --- Initialize PIL images and dimensions ---
if image is not None:
img_mode = 'RGBA' if image.shape[-1] == 4 else 'RGB'
img_pil = Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8), mode=img_mode)
# Always convert to RGBA if a mask is connected
# OR if the image itself already has an alpha channel
if img_pil.mode != 'RGBA':
img_pil = img_pil.convert('RGBA')
current_width, current_height = img_pil.size
if is_mask_connected:
mask_np = np.clip(mask.cpu().numpy().squeeze() * 255., 0, 255).astype(np.uint8)
mask_pil = Image.fromarray(mask_np, mode='L')
# If only a mask is provided
if not img_pil:
current_width, current_height = mask_pil.size
elif image is not None:
# If no mask is connected but there's an
# image, create a full white mask (fully opaque)
mask_pil = Image.new('L', (current_width, current_height), color=255)
if img_pil and mask_pil and (img_pil.size != mask_pil.size):
mask_pil = mask_pil.resize(img_pil.size, Image.NEAREST)
# --- Apply Offset ---
unified_fill_color_tuple = self._parse_color_string(fill_color)
if img_pil:
if wrap_around == "On": img_pil = ImageChops.offset(img_pil, offset_x, offset_y)
else:
temp_img = Image.new("RGBA", (current_width, current_height), unified_fill_color_tuple)
# Paste the original image (which might
# have its own alpha) onto the fill_color background
temp_img.paste(img_pil, (offset_x, offset_y), img_pil.split()[-1] if img_pil.mode == 'RGBA' else None); img_pil = temp_img
if mask_pil:
# 255 if mask is connected, 0 if it's a dummy
mask_fill_value = 255 if is_mask_connected else 0
if wrap_around == "On": mask_pil = ImageChops.offset(mask_pil, offset_x, offset_y)
else:
temp_mask_img = Image.new('L', (current_width, current_height), color=mask_fill_value)
temp_mask_img.paste(mask_pil, (offset_x, offset_y)); mask_pil = temp_mask_img
# --- Apply incoming mask to image's alpha channel ---
# If an input mask is connected, use it to control transparency.
# We invert the mask here because a common convention
# is that black in mask means 'visible' while
# PIL's putalpha uses black for transparent.
if img_pil and is_mask_connected:
# Ensure img_pil has an alpha channel
# before applying the mask
if img_pil.mode != 'RGBA':
img_pil = img_pil.convert('RGBA')
# Invert the mask for use as alpha, so black areas
# in the input mask become opaque in the image
inverted_mask_for_alpha = ImageOps.invert(mask_pil)
img_pil.putalpha(inverted_mask_for_alpha)
# --- Composite image over fill_color background ---
# This makes the fill_color appear behind the
# transparent areas of the image and converts
# the image back to 3 channels.
if img_pil:
# Ensure the fill_color tuple has an opaque alpha
# (e.g., (R,G,B,255)) for a solid background
# If user only provided RGB, ensure opaque alpha
if len(unified_fill_color_tuple) == 3:
fill_color_for_composite = unified_fill_color_tuple + (255,)
# Use the alpha provided by user,
# or default (0,0,0,0) if parsing failed
else:
fill_color_for_composite = unified_fill_color_tuple
# Create a new solid background image with the fill_color
background_img = Image.new("RGBA", img_pil.size, fill_color_for_composite)
# Composite the (potentially transparent) img_pil onto the background
img_pil = Image.alpha_composite(background_img, img_pil)
# After compositing, convert to RGB to flatten the image
img_pil = img_pil.convert("RGB")
# --- Invert Mask for output (if toggle is "Yes") ---
if mask_pil and invert_mask_output == "Yes":
mask_pil = ImageOps.invert(mask_pil)
# --- Convert back to torch tensors ---
# Output image is now 3-channel (RGB)
# because it's composited with fill_color
output_image = torch.zeros(1, current_height, current_width, 3)
if img_pil:
img_array = np.array(img_pil).astype(np.float32) / 255.0 # No need to convert to RGB here, it's already done
output_image = torch.from_numpy(img_array)[None,]
output_mask = torch.zeros(1, current_height, current_width)
if mask_pil:
mask_np_float = np.array(mask_pil).astype(np.float32) / 255.0
# If an input mask was connected, the output mask
# should be 1.0 - input mask (ComfyUI typically treats
# white as masked, black as unmasked for output)
if is_mask_connected:
output_mask = 1.0 - mask_np_float
else:
# If no mask was connected,
# just use the generated full mask
output_mask = mask_np_float
output_mask = torch.from_numpy(output_mask)[None,]
output_info = f"Offset: ({offset_x}, {offset_y}), Size: {current_width}x{current_height}, Wrapped: {wrap_around}, Mask Output Inverted: {invert_mask_output}"
return (output_image, output_mask, offset_x, offset_y, output_info)