Files
bemoregt-ComfyUI_PhaseStret…/pst_node.py
T
bemoregtandClaude Sonnet 4.6 26184e04a1 fix: use abs(phase) for soft edge map output
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>
2026-03-22 18:15:49 +09:00

165 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 – |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