offset_y was following the raw pixel convention where Y grows downward, so dragging the slider right pushed the image down. Negated it in build_matrix so both placement sliders now read the same way: drag right for right/up, drag left for left/down. One edit covers both nodes, since Image Transform Pro and Remove Background Pro share build_matrix. This is the opposite sign to Photoshop's transform Y field, which is a deliberate choice: on a centre-zero slider "more" reading as "up" is more intuitive than matching pixel coordinates. Category is now "MachinePaintingNodes/Rotate & Scale" - a single subcategory using "&" rather than "/", which ComfyUI would have split into nested submenus. Keeps the node one level deep like the rest of the pack. README heading updated to match.
126 lines
4.3 KiB
Python
126 lines
4.3 KiB
Python
import cv2
|
|
import numpy as np
|
|
|
|
"""
|
|
Shared geometric transform helpers.
|
|
|
|
Used by ImageTransformPro and RemoveBackgroundPro so both nodes run identical
|
|
rotate / zoom / offset math and stay registered to each other.
|
|
|
|
Conventions:
|
|
angle degrees. Positive = clockwise (slider right), negative =
|
|
counter-clockwise (slider left).
|
|
zoom -100..100, centred on 0. Exponential, see zoom_to_scale().
|
|
offset_x/y percent (-100..100) of the destination canvas. Positive x moves
|
|
right, positive y moves UP, so both sliders read the same way:
|
|
drag right for right/up, drag left for left/down. Note this is
|
|
the opposite sign to a raw pixel Y, which grows downward.
|
|
"""
|
|
|
|
# Snap increment used by the "fixed" rotation method
|
|
FIXED_SNAP_DEGREES = 45.0
|
|
|
|
|
|
def snap_angle(angle, method):
|
|
"""Snap to FIXED_SNAP_DEGREES increments when method is 'fixed'."""
|
|
if method == "fixed":
|
|
return round(float(angle) / FIXED_SNAP_DEGREES) * FIXED_SNAP_DEGREES
|
|
return float(angle)
|
|
|
|
|
|
def zoom_to_scale(zoom):
|
|
"""
|
|
Map the -100..100 zoom slider to a scale multiplier.
|
|
|
|
-100 -> 0.25x, -50 -> 0.5x, 0 -> 1.0x, +50 -> 2.0x, +100 -> 4.0x
|
|
Exponential so a drag left shrinks as much as the same drag right grows.
|
|
"""
|
|
return float(2.0 ** (float(zoom) / 50.0))
|
|
|
|
|
|
def fit_scale(src_shape, dst_shape):
|
|
"""Scale that fits the source fully inside the canvas, aspect preserved."""
|
|
src_h, src_w = src_shape[:2]
|
|
dst_h, dst_w = dst_shape[:2]
|
|
return min(dst_w / float(src_w), dst_h / float(src_h))
|
|
|
|
|
|
def build_matrix(src_shape, dst_shape, angle, scale, offset_x=0.0, offset_y=0.0):
|
|
"""Build the 2x3 affine mapping the source image into the destination canvas."""
|
|
src_h, src_w = src_shape[:2]
|
|
dst_h, dst_w = dst_shape[:2]
|
|
|
|
src_cx, src_cy = (src_w - 1) * 0.5, (src_h - 1) * 0.5
|
|
dst_cx, dst_cy = (dst_w - 1) * 0.5, (dst_h - 1) * 0.5
|
|
|
|
# cv2 treats positive angles as counter-clockwise, so negate to put CCW on
|
|
# the left of the slider and CW on the right.
|
|
M = cv2.getRotationMatrix2D((src_cx, src_cy), -float(angle), float(scale))
|
|
|
|
# Re-centre on the canvas, then apply the percent offsets. offset_y is
|
|
# negated so a positive value moves the image UP: canvas Y grows downward,
|
|
# so subtracting shifts toward the top.
|
|
M[0, 2] += (dst_cx - src_cx) + (float(offset_x) / 100.0) * dst_w
|
|
M[1, 2] += (dst_cy - src_cy) - (float(offset_y) / 100.0) * dst_h
|
|
|
|
return M
|
|
|
|
|
|
def warp_image(img, M, dst_shape):
|
|
"""Warp a float32 HxWx3 image. Anything outside the source becomes 0."""
|
|
dst_h, dst_w = dst_shape[:2]
|
|
out = cv2.warpAffine(
|
|
img, M, (dst_w, dst_h),
|
|
flags=cv2.INTER_LANCZOS4,
|
|
borderMode=cv2.BORDER_CONSTANT,
|
|
borderValue=0,
|
|
)
|
|
# Lanczos can overshoot slightly at high-contrast edges
|
|
return np.clip(out, 0.0, 1.0)
|
|
|
|
|
|
def warp_single(data, M, dst_shape):
|
|
"""Warp a float32 HxW plane (mask or coverage) with the same matrix."""
|
|
dst_h, dst_w = dst_shape[:2]
|
|
out = cv2.warpAffine(
|
|
data, M, (dst_w, dst_h),
|
|
flags=cv2.INTER_LINEAR,
|
|
borderMode=cv2.BORDER_CONSTANT,
|
|
borderValue=0,
|
|
)
|
|
return np.clip(out, 0.0, 1.0)
|
|
|
|
|
|
def coverage_mask(src_shape, M, dst_shape):
|
|
"""1.0 where source pixels landed on the canvas, 0.0 in the empty corners."""
|
|
src_h, src_w = src_shape[:2]
|
|
ones = np.ones((src_h, src_w), dtype=np.float32)
|
|
return warp_single(ones, M, dst_shape)
|
|
|
|
|
|
def transparency_grid(shape, check_size=16, light=160.0, dark=140.0):
|
|
"""Soft checkerboard used to preview transparent areas in-node."""
|
|
h, w = shape[:2]
|
|
ys = (np.arange(h) // check_size)[:, None]
|
|
xs = (np.arange(w) // check_size)[None, :]
|
|
grid = np.where((ys + xs) % 2 == 0, light, dark).astype(np.float32)
|
|
return np.stack([grid, grid, grid], axis=2)
|
|
|
|
|
|
def normalize_mask(mask_tensor, target_shape):
|
|
"""MASK tensor -> float32 HxW in 0..1, resized to target_shape."""
|
|
if len(mask_tensor.shape) == 3:
|
|
mask_np = mask_tensor[0].cpu().numpy()
|
|
else:
|
|
mask_np = mask_tensor.cpu().numpy()
|
|
|
|
mask_np = mask_np.astype(np.float32)
|
|
if mask_np.max() > 1.0:
|
|
mask_np = mask_np / 255.0
|
|
|
|
h, w = target_shape[:2]
|
|
if mask_np.shape[:2] != (h, w):
|
|
mask_np = cv2.resize(mask_np, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
|
|
return mask_np
|