Files
bemoregt-ComfyUI_PhaseStret…/pst_node.py
T
bemoregtandClaude Sonnet 4.6 ce73aaa91c fix: correct PST kernel formula per paper Eq.5
Previous kernel incorrectly modified both amplitude and phase:
  (W*R/(1+W*R)) * exp(-j*S*R/(1+R))

Corrected to unit-amplitude pure-phase kernel as defined in the paper:
  K = exp(j * S * W*R/(1+W*R))

Also updated default thresholds to symmetric ±0.35 which is appropriate
for the corrected phase range of approximately [-S, S] = [-0.48, 0.48].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:03:05 +09:00

163 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 – normalise phase to 0..1
edges = (phase - phase.min()) / (phase.max() - phase.min() + 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 (normalised to 0..1, RGB) ───────────────────────
phase_norm = (phase - phase.min()) / (phase.max() - phase.min() + 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