Previous soft mode (morph_flag=False) used linear phase normalization which spread noise across the full brightness range. abs(phase) makes edges bright and flat regions dark, matching the paper's Fig.4(C) style continuous edge map. Same fix applied to phase_image output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
6.8 KiB
Python
165 lines
6.8 KiB
Python
import numpy as np
|
||
import torch
|
||
from scipy import ndimage
|
||
|
||
|
||
def phase_stretch_transform(img_gray: np.ndarray,
|
||
lpf_sigma: float = 0.21,
|
||
phase_strength: float = 0.48,
|
||
warp_strength: float = 12.14,
|
||
morph_flag: bool = True,
|
||
min_thresh: float = -0.35,
|
||
max_thresh: float = 0.35) -> tuple[np.ndarray, np.ndarray]:
|
||
"""
|
||
Phase Stretch Transform (PST)
|
||
|
||
Returns:
|
||
phase_image : raw phase output (float, roughly -pi..pi)
|
||
edge_image : binary or soft edge map (float 0..1)
|
||
"""
|
||
H, W = img_gray.shape
|
||
L = 0.5
|
||
|
||
fx = np.linspace(-L, L, W)
|
||
fy = np.linspace(-L, L, H)
|
||
FX, FY = np.meshgrid(fx, fy)
|
||
R = np.sqrt(FX ** 2 + FY ** 2) # radial frequency grid
|
||
|
||
# ── Low-pass filter (Gaussian) ───────────────────────────────────────────
|
||
lpf = np.exp(-0.5 * (R / (lpf_sigma + 1e-9)) ** 2)
|
||
|
||
img_fft = np.fft.fftshift(np.fft.fft2(img_gray.astype(np.float64)))
|
||
img_filtered = img_fft * lpf
|
||
|
||
# ── PST kernel (warped phase ramp, Eq.5 in paper) ────────────────────────
|
||
# K[u,v] = exp(j * S * phi_w) where phi_w = W*R / (1 + W*R)
|
||
# Unit-amplitude, pure-phase kernel — amplitude must NOT be modified.
|
||
W_val = warp_strength
|
||
S_val = phase_strength
|
||
phi_w = W_val * R / (1.0 + W_val * R) # warped phase ramp ∈ [0, 1)
|
||
pst_kernel = np.exp(1j * S_val * phi_w) # pure phase kernel
|
||
|
||
img_pst = np.fft.ifft2(np.fft.ifftshift(img_filtered * pst_kernel))
|
||
|
||
# ── Phase angle ──────────────────────────────────────────────────────────
|
||
phase = np.angle(img_pst) # range: -π … π
|
||
|
||
if morph_flag:
|
||
edges = ((phase > max_thresh) | (phase < min_thresh)).astype(np.float64)
|
||
# small morphological clean-up
|
||
edges = ndimage.binary_closing(edges, structure=np.ones((3, 3))).astype(np.float64)
|
||
edges = ndimage.binary_opening(edges, structure=np.ones((3, 3))).astype(np.float64)
|
||
else:
|
||
# soft edge map – |phase| normalised: edges are bright, flat regions are dark
|
||
abs_phase = np.abs(phase)
|
||
edges = abs_phase / (abs_phase.max() + 1e-9)
|
||
|
||
return phase, edges
|
||
|
||
|
||
class PhaseStretchTransformNode:
|
||
"""ComfyUI node – Phase Stretch Transform edge detector."""
|
||
|
||
CATEGORY = "image/processing"
|
||
RETURN_TYPES = ("IMAGE", "IMAGE")
|
||
RETURN_NAMES = ("edge_image", "phase_image")
|
||
FUNCTION = "run"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"lpf_sigma": ("FLOAT", {
|
||
"default": 0.21, "min": 0.01, "max": 1.0, "step": 0.01,
|
||
"tooltip": "Low-pass filter sigma – higher = more smoothing before PST"
|
||
}),
|
||
"phase_strength": ("FLOAT", {
|
||
"default": 0.48, "min": 0.0, "max": 3.0, "step": 0.01,
|
||
"tooltip": "PST phase strength (S)"
|
||
}),
|
||
"warp_strength": ("FLOAT", {
|
||
"default": 12.14, "min": 0.0, "max": 100.0, "step": 0.1,
|
||
"tooltip": "PST warp strength (W)"
|
||
}),
|
||
"min_thresh": ("FLOAT", {
|
||
"default": -0.35, "min": -3.15, "max": 0.0, "step": 0.001,
|
||
"tooltip": "Lower phase threshold for binary edge map (range ≈ -phase_strength to 0)"
|
||
}),
|
||
"max_thresh": ("FLOAT", {
|
||
"default": 0.35, "min": 0.0, "max": 3.15, "step": 0.001,
|
||
"tooltip": "Upper phase threshold for binary edge map (range ≈ 0 to phase_strength)"
|
||
}),
|
||
"morph_flag": ("BOOLEAN", {
|
||
"default": True,
|
||
"tooltip": "Apply morphological clean-up (binary edges); "
|
||
"False returns normalised phase image"
|
||
}),
|
||
"output_mode": (["grayscale", "rgb_overlay"], {
|
||
"default": "grayscale",
|
||
"tooltip": "grayscale = white edges on black; "
|
||
"rgb_overlay = edges overlaid on original"
|
||
}),
|
||
},
|
||
}
|
||
|
||
# ------------------------------------------------------------------
|
||
def run(self, image: torch.Tensor,
|
||
lpf_sigma: float,
|
||
phase_strength: float,
|
||
warp_strength: float,
|
||
min_thresh: float,
|
||
max_thresh: float,
|
||
morph_flag: bool,
|
||
output_mode: str):
|
||
"""
|
||
image shape: (B, H, W, C) – ComfyUI convention, float32 0..1
|
||
"""
|
||
B, H, W, C = image.shape
|
||
edge_frames = []
|
||
phase_frames = []
|
||
|
||
for b in range(B):
|
||
frame = image[b].cpu().numpy() # (H, W, C)
|
||
|
||
# Convert to grayscale for PST (luminance weights)
|
||
if C == 1:
|
||
gray = frame[..., 0]
|
||
else:
|
||
gray = 0.2126 * frame[..., 0] + \
|
||
0.7152 * frame[..., 1] + \
|
||
0.0722 * frame[..., 2]
|
||
|
||
phase, edges = phase_stretch_transform(
|
||
gray,
|
||
lpf_sigma=lpf_sigma,
|
||
phase_strength=phase_strength,
|
||
warp_strength=warp_strength,
|
||
morph_flag=morph_flag,
|
||
min_thresh=min_thresh,
|
||
max_thresh=max_thresh,
|
||
)
|
||
|
||
# ── edge output ──────────────────────────────────────────────────
|
||
if output_mode == "rgb_overlay":
|
||
overlay = frame.copy()
|
||
mask = edges > 0.5
|
||
overlay[mask] = np.array([1.0, 0.2, 0.2], dtype=np.float32)
|
||
edge_out = overlay.astype(np.float32)
|
||
else:
|
||
edge_rgb = np.stack([edges] * 3, axis=-1).astype(np.float32)
|
||
edge_out = edge_rgb
|
||
|
||
# ── phase output: |phase| normalised – edges bright, flat regions dark
|
||
abs_phase = np.abs(phase)
|
||
phase_norm = abs_phase / (abs_phase.max() + 1e-9)
|
||
phase_out = np.stack([phase_norm] * 3, axis=-1).astype(np.float32)
|
||
|
||
edge_frames.append(torch.from_numpy(edge_out))
|
||
phase_frames.append(torch.from_numpy(phase_out))
|
||
|
||
edge_tensor = torch.stack(edge_frames, dim=0) # (B, H, W, 3)
|
||
phase_tensor = torch.stack(phase_frames, dim=0) # (B, H, W, 3)
|
||
|
||
return edge_tensor, phase_tensor
|