"Rotate" is the word people actually search for, and almost nothing in the ecosystem registers under that name, so keeping it in the display name is worth more than a tidier one. The node menu now shows "Transform & Rotate Pro", and the class is TransformRotatePro to match. The registration key stays MP_ImageTransform on purpose. That key is what saved workflows and placed nodes reference, so changing it again would break any node already dropped on a canvas. The key is never shown in the UI, so there is no cost to leaving it as it is - a comment in __init__.py records why it differs from the class name. Category is unchanged at MachinePaintingNodes/Rotate & Scale. README updated throughout.
126 lines
4.3 KiB
Python
126 lines
4.3 KiB
Python
import cv2
|
|
import numpy as np
|
|
|
|
"""
|
|
Shared geometric transform helpers.
|
|
|
|
Used by TransformRotatePro 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
|