Sharpen: add curvature_guided backend (GPU, prototype)
The second-order analogue of guided_normal: instead of filtering normals and projecting onto planes, it filters the CURVATURE field and reconstructs via a Laplacian-domain solve (differential-coordinate / Laplacian editing; Sorkine 2004, Lipman 2004). Pipeline (torch sparse, CUDA): delta = L x (differential coords; |delta| ~ mean- curvature normal) -> edge-aware bilateral filter of scalar |H| over the 1-ring, range-weighted by normal difference (diffuses curvature within a region, stops at edges) -> rebuild delta_f keeping each vertex's own direction -> reconstruct by preconditioned-CG solving (L^T L + lambda I) x = L^T delta_f + lambda x0. Key property (verified): magnitude-only filtering makes a sphere/cylinder a fixed point (no flattening, no shrink). direction_blend>0 additionally smooths the delta direction to denoise positional noise at some flattening risk (verified to move a noisy sphere toward the clean one while staying spherical). Knobs: iterations, sigma_s, sigma_r (deg), anchor_weight (Tikhonov), direction_blend, cg_iters, use_gpu. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
250f0855b0
commit
723d679ef8
@@ -54,6 +54,7 @@ class SharpenMeshNode(io.ComfyNode):
|
||||
"guided_normal": "GeomPackSharpen_GuidedNormal",
|
||||
"fast_effective": "GeomPackSharpen_FastEffective",
|
||||
"non_iterative": "GeomPackSharpen_NonIterative",
|
||||
"curvature_guided": "GeomPackSharpen_CurvatureGuided",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -226,6 +227,39 @@ class SharpenMeshNode(io.ComfyNode):
|
||||
"more feature-preserving."
|
||||
)),
|
||||
]),
|
||||
io.DynamicCombo.Option("curvature_guided", [
|
||||
io.Int.Input("iterations", default=5, min=0, max=100, step=1, tooltip=(
|
||||
"Edge-aware diffusion passes on the CURVATURE field (2nd-order). "
|
||||
"Unlike guided_normal (which flattens curved regions), this filters "
|
||||
"curvature magnitude and reconstructs via a Laplacian solve, so it "
|
||||
"denoises within a region WITHOUT flattening cylinders/fillets. "
|
||||
"More = stronger agreement / wider reach. 0 = identity."
|
||||
)),
|
||||
io.Float.Input("sigma_s", default=2.0, min=0.1, max=10.0, step=0.1, tooltip=(
|
||||
"Spatial scale (x average edge length) for curvature diffusion."
|
||||
)),
|
||||
io.Float.Input("sigma_r_degrees", default=20.0, min=1.0, max=120.0, step=1.0, tooltip=(
|
||||
"Range scale in DEGREES: normal-difference angle at which curvature "
|
||||
"STOPS diffusing across an edge. Smaller = sharper feature preservation."
|
||||
)),
|
||||
io.Float.Input("anchor_weight", default=0.1, min=0.001, max=10.0, step=0.001, display_mode="number", tooltip=(
|
||||
"How strongly the reconstruction sticks to the input positions "
|
||||
"(Tikhonov lambda). LOWER = stronger curvature-domain reshaping; "
|
||||
"HIGHER = stay close to input. Default 0.1."
|
||||
)),
|
||||
io.Float.Input("direction_blend", default=0.0, min=0.0, max=1.0, step=0.05, tooltip=(
|
||||
"0 = filter curvature MAGNITUDE only (spheres/cylinders are a fixed "
|
||||
"point, no flattening, but positional noise survives). >0 also "
|
||||
"smooths the direction to denoise positions, at some flattening risk "
|
||||
"in curved regions. Try 0.3-0.6 to denoise; 0 to purely uniformize."
|
||||
)),
|
||||
io.Int.Input("cg_iters", default=200, min=10, max=2000, step=10, tooltip=(
|
||||
"Max preconditioned-CG iterations for the reconstruction solve."
|
||||
)),
|
||||
io.Combo.Input("use_gpu", options=["true", "false"], default="true", tooltip=(
|
||||
"Run on CUDA (recommended -- torch sparse mat-vecs). false = CPU torch."
|
||||
)),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
outputs=[
|
||||
|
||||
@@ -12,6 +12,7 @@ from .l0_minimize import NODE_CLASS_MAPPINGS as L0_MAPS, NODE_DISPLAY_NAME_MAPPI
|
||||
from .guided_normal import NODE_CLASS_MAPPINGS as GN_MAPS, NODE_DISPLAY_NAME_MAPPINGS as GN_DISP
|
||||
from .fast_effective import NODE_CLASS_MAPPINGS as FE_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FE_DISP
|
||||
from .non_iterative import NODE_CLASS_MAPPINGS as NI_MAPS, NODE_DISPLAY_NAME_MAPPINGS as NI_DISP
|
||||
from .curvature_guided import NODE_CLASS_MAPPINGS as CG_MAPS, NODE_DISPLAY_NAME_MAPPINGS as CG_DISP
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
@@ -23,6 +24,7 @@ NODE_CLASS_MAPPINGS.update(L0_MAPS)
|
||||
NODE_CLASS_MAPPINGS.update(GN_MAPS)
|
||||
NODE_CLASS_MAPPINGS.update(FE_MAPS)
|
||||
NODE_CLASS_MAPPINGS.update(NI_MAPS)
|
||||
NODE_CLASS_MAPPINGS.update(CG_MAPS)
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(TS_DISP)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(UM_DISP)
|
||||
@@ -31,5 +33,6 @@ NODE_DISPLAY_NAME_MAPPINGS.update(L0_DISP)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(GN_DISP)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(FE_DISP)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(NI_DISP)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(CG_DISP)
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
|
||||
|
||||
"""Curvature-domain guided sharpening backend (GPU, prototype).
|
||||
|
||||
The second-order analogue of guided_normal. Where guided_normal filters face
|
||||
NORMALS (first order) and reconstructs by plane projection, this filters the
|
||||
mesh CURVATURE and reconstructs by a Laplacian-domain solve.
|
||||
|
||||
Pipeline (all on torch / CUDA):
|
||||
1. delta = L x -- the differential (Laplacian) coordinates; |delta_i|
|
||||
is the (area-weighted) mean-curvature normal, so
|
||||
H_i = |delta_i| / (2 A_i) is pointwise mean curvature.
|
||||
2. Edge-aware bilateral filter of the SCALAR curvature |H| over the 1-ring,
|
||||
range-weighted by NORMAL difference so it diffuses curvature WITHIN a region
|
||||
but stops at sharp edges -> "increase curvature agreement within regions".
|
||||
We filter the magnitude only and keep each vertex's own delta DIRECTION, so
|
||||
a perfect sphere/cylinder is a fixed point (no flattening).
|
||||
3. Rebuild target delta_f = (2 H_f A_i) * (delta_i / |delta_i|).
|
||||
4. Reconstruct positions: minimise ||L x - delta_f||^2 + lambda||x - x0||^2,
|
||||
i.e. solve (L^T L + lambda I) x = L^T delta_f + lambda x0 with a diagonally
|
||||
preconditioned conjugate gradient (sparse mat-vecs -> GPU friendly).
|
||||
|
||||
References: Laplacian/differential-coordinate surface editing (Sorkine et al.
|
||||
2004; Lipman et al. 2004); curvature-domain shape processing (Eigensatz et al.
|
||||
2008). This is a linearised (quadratic-bending) prototype.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import trimesh as trimesh_module
|
||||
from comfy_api.latest import io
|
||||
|
||||
log = logging.getLogger("geometrypack")
|
||||
|
||||
|
||||
def _torch_device(use_gpu):
|
||||
import torch
|
||||
if use_gpu:
|
||||
try:
|
||||
import comfy.model_management as mm
|
||||
dev = mm.get_torch_device()
|
||||
if dev is not None and dev.type == "cuda":
|
||||
return dev
|
||||
except Exception:
|
||||
pass
|
||||
if torch.cuda.is_available():
|
||||
return torch.device("cuda")
|
||||
return torch.device("cpu")
|
||||
|
||||
|
||||
def _curvature_guided(mesh, iterations, sigma_s, sigma_r, anchor_weight, cg_iters, use_gpu,
|
||||
direction_blend=0.0):
|
||||
import torch
|
||||
import igl
|
||||
|
||||
V0 = np.asarray(mesh.vertices, dtype=np.float64)
|
||||
Ff = np.asarray(mesh.faces, dtype=np.int64)
|
||||
if len(V0) == 0 or len(Ff) == 0:
|
||||
return None, "Empty mesh (no vertices or faces).", "cpu"
|
||||
|
||||
n = len(V0)
|
||||
dev = _torch_device(use_gpu)
|
||||
eps = 1e-12
|
||||
|
||||
# --- cotangent Laplacian (symmetric) as a torch sparse tensor ---
|
||||
L = igl.cotmatrix(V0, Ff).tocoo()
|
||||
Lidx = torch.tensor(np.vstack([L.row, L.col]), dtype=torch.long, device=dev)
|
||||
Lval = torch.tensor(L.data, dtype=torch.float32, device=dev)
|
||||
Lsp = torch.sparse_coo_tensor(Lidx, Lval, (n, n)).coalesce()
|
||||
|
||||
M = igl.massmatrix(V0, Ff, igl.MASSMATRIX_TYPE_VORONOI)
|
||||
Mdiag = torch.tensor(np.asarray(M.diagonal(), dtype=np.float64), dtype=torch.float32, device=dev)
|
||||
Mdiag = torch.clamp(Mdiag, min=1e-12)
|
||||
|
||||
X0 = torch.tensor(V0, dtype=torch.float32, device=dev)
|
||||
|
||||
def spmm(A, X):
|
||||
return torch.sparse.mm(A, X)
|
||||
|
||||
delta = spmm(Lsp, X0) # (n,3) differential coords
|
||||
dmag = delta.norm(dim=1) # (n,)
|
||||
Hmag = dmag / (2.0 * Mdiag) # pointwise |mean curvature|
|
||||
direction = delta / (dmag.unsqueeze(1) + eps)
|
||||
|
||||
# --- 1-ring directed edges (both directions) for the bilateral filter ---
|
||||
E = np.vstack([Ff[:, [0, 1]], Ff[:, [1, 2]], Ff[:, [2, 0]]])
|
||||
ed = np.unique(np.vstack([E, E[:, [1, 0]]]), axis=0)
|
||||
src = torch.tensor(ed[:, 0], dtype=torch.long, device=dev) # accumulate into i
|
||||
dst = torch.tensor(ed[:, 1], dtype=torch.long, device=dev) # neighbour j
|
||||
|
||||
VN = torch.tensor(np.asarray(mesh.vertex_normals, dtype=np.float64), dtype=torch.float32, device=dev)
|
||||
avg_edge = float(np.mean(np.linalg.norm(V0[E[:, 0]] - V0[E[:, 1]], axis=1)))
|
||||
ss2 = 2.0 * (sigma_s * avg_edge) ** 2
|
||||
sr2 = 2.0 * (sigma_r ** 2)
|
||||
dpos = (X0[src] - X0[dst]).pow(2).sum(1)
|
||||
dnrm = (VN[src] - VN[dst]).pow(2).sum(1)
|
||||
w = torch.exp(-dpos / (ss2 + eps)) * torch.exp(-dnrm / (sr2 + eps)) # (E,)
|
||||
wsum = torch.ones(n, device=dev) # self weight 1.0
|
||||
wsum.index_add_(0, src, w)
|
||||
|
||||
# --- edge-aware diffusion of the SCALAR curvature magnitude ---
|
||||
Hf = Hmag.clone()
|
||||
for _ in range(int(iterations)):
|
||||
acc = Hf.clone() # self term
|
||||
acc.index_add_(0, src, w * Hf[dst])
|
||||
Hf = acc / wsum
|
||||
|
||||
# Optionally also smooth the delta DIRECTION. magnitude-only (blend=0) keeps each
|
||||
# vertex's own orientation -> a sphere/cylinder is a fixed point (no flattening) but
|
||||
# positional noise (wrong directions) survives. blend>0 mixes in an edge-aware
|
||||
# smoothed direction, which denoises positions at some flattening risk in curved
|
||||
# regions (the sigma_r range weight still protects sharp edges either way).
|
||||
if direction_blend > 0.0:
|
||||
vdf = delta.clone()
|
||||
for _ in range(int(iterations)):
|
||||
acc = vdf.clone()
|
||||
acc.index_add_(0, src, w.unsqueeze(1) * vdf[dst])
|
||||
vdf = acc / wsum.unsqueeze(1)
|
||||
unit_v = vdf / (vdf.norm(dim=1, keepdim=True) + eps)
|
||||
direction = direction * (1.0 - direction_blend) + unit_v * direction_blend
|
||||
direction = direction / (direction.norm(dim=1, keepdim=True) + eps)
|
||||
|
||||
# rebuild target delta with smoothed magnitude and (optionally) smoothed direction
|
||||
delta_f = (2.0 * Hf * Mdiag).unsqueeze(1) * direction
|
||||
|
||||
# --- reconstruct: (L^T L + lam I) x = L^T delta_f + lam x0 (L symmetric) ---
|
||||
scale = float((Lval * Lval).mean().item()) # ~ magnitude of (L^T L) diagonal
|
||||
lam = float(anchor_weight) * scale
|
||||
b = spmm(Lsp, delta_f) + lam * X0
|
||||
|
||||
def matvec(X):
|
||||
return spmm(Lsp, spmm(Lsp, X)) + lam * X
|
||||
|
||||
# diagonal (Jacobi) preconditioner: diag(L^T L)_i = sum_k L_ik^2
|
||||
diagLL = torch.zeros(n, device=dev)
|
||||
diagLL.index_add_(0, Lidx[0], Lval * Lval)
|
||||
Minv = 1.0 / (diagLL + lam)
|
||||
|
||||
# preconditioned CG over the 3 position columns simultaneously
|
||||
X = X0.clone()
|
||||
r = b - matvec(X)
|
||||
z = Minv.unsqueeze(1) * r
|
||||
p = z.clone()
|
||||
rz = (r * z).sum(0)
|
||||
r0 = torch.sqrt((r * r).sum(0)).max().item()
|
||||
used = 0
|
||||
for _ in range(int(cg_iters)):
|
||||
used += 1
|
||||
Ap = matvec(p)
|
||||
alpha = rz / ((p * Ap).sum(0) + 1e-20)
|
||||
X = X + p * alpha
|
||||
r = r - Ap * alpha
|
||||
if torch.sqrt((r * r).sum(0)).max().item() < 1e-6 * (r0 + 1e-12):
|
||||
break
|
||||
z = Minv.unsqueeze(1) * r
|
||||
rz_new = (r * z).sum(0)
|
||||
p = z + p * (rz_new / (rz + 1e-20))
|
||||
rz = rz_new
|
||||
|
||||
Vout = X.detach().cpu().numpy().astype(np.float64)
|
||||
result = trimesh_module.Trimesh(vertices=Vout, faces=np.asarray(mesh.faces, dtype=np.int32), process=False)
|
||||
info_dev = f"{dev} (cg {used} iters)"
|
||||
return result, "", info_dev
|
||||
|
||||
|
||||
class SharpenCurvatureGuidedNode(io.ComfyNode):
|
||||
"""Curvature-domain guided sharpening backend (prototype, GPU)."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="GeomPackSharpen_CurvatureGuided",
|
||||
display_name="Sharpen Curvature Guided (backend)",
|
||||
category="geompack/smoothing",
|
||||
is_dev_only=True,
|
||||
is_output_node=True,
|
||||
inputs=[
|
||||
io.Custom("TRIMESH").Input("trimesh"),
|
||||
io.Int.Input("iterations", default=5, min=0, max=100, step=1, tooltip=(
|
||||
"Edge-aware diffusion passes on the curvature field. More = stronger "
|
||||
"curvature agreement within regions / wider reach. 0 = identity.")),
|
||||
io.Float.Input("sigma_s", default=2.0, min=0.1, max=10.0, step=0.1, tooltip=(
|
||||
"Spatial scale (x average edge length) for the curvature diffusion.")),
|
||||
io.Float.Input("sigma_r_degrees", default=20.0, min=1.0, max=120.0, step=1.0, tooltip=(
|
||||
"Range scale in DEGREES: normal-difference angle at which curvature "
|
||||
"STOPS diffusing across an edge. Smaller = sharper feature preservation.")),
|
||||
io.Float.Input("anchor_weight", default=0.1, min=0.001, max=10.0, step=0.001, display_mode="number", tooltip=(
|
||||
"How strongly the reconstruction sticks to the input positions "
|
||||
"(Tikhonov lambda, relative to the Laplacian scale). LOWER = stronger "
|
||||
"curvature-domain reshaping; HIGHER = stay close to input. Default 0.1.")),
|
||||
io.Float.Input("direction_blend", default=0.0, min=0.0, max=1.0, step=0.05, tooltip=(
|
||||
"0 = filter curvature MAGNITUDE only, keeping each vertex's own normal "
|
||||
"direction -> spheres/cylinders are a fixed point (no flattening), but "
|
||||
"positional noise survives. >0 mixes in an edge-aware smoothed direction "
|
||||
"to also denoise positions, at some flattening risk in curved regions. "
|
||||
"Try 0.3-0.6 to denoise; 0 to purely uniformize curvature.")),
|
||||
io.Int.Input("cg_iters", default=200, min=10, max=2000, step=10, tooltip=(
|
||||
"Max preconditioned-CG iterations for the reconstruction solve. "
|
||||
"Increase if the result looks under-converged on large meshes.")),
|
||||
io.Combo.Input("use_gpu", options=["true", "false"], default="true", tooltip=(
|
||||
"Run on CUDA (recommended -- this backend is torch sparse mat-vecs). "
|
||||
"false = CPU torch (works, slower).")),
|
||||
],
|
||||
outputs=[
|
||||
io.Custom("TRIMESH").Output(display_name="sharpened_mesh"),
|
||||
io.String.Output(display_name="info"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, trimesh, iterations=5, sigma_s=2.0, sigma_r_degrees=20.0,
|
||||
anchor_weight=0.1, direction_blend=0.0, cg_iters=200, use_gpu="true"):
|
||||
import math
|
||||
import time
|
||||
sigma_r = 2.0 * math.sin(math.radians(sigma_r_degrees) / 2.0)
|
||||
iv, ifc = len(trimesh.vertices), len(trimesh.faces)
|
||||
log.info("Backend: curvature_guided | %d verts %d faces | iters=%d sigma_s=%.2f sigma_r=%.1fdeg anchor=%.3f dirblend=%.2f gpu=%s",
|
||||
iv, ifc, iterations, sigma_s, sigma_r_degrees, anchor_weight, direction_blend, use_gpu)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
sharpened, error, dev = _curvature_guided(
|
||||
trimesh, iterations, sigma_s, sigma_r, anchor_weight, cg_iters, use_gpu == "true",
|
||||
direction_blend=direction_blend)
|
||||
elapsed = time.perf_counter() - t0
|
||||
if sharpened is None:
|
||||
raise ValueError(f"Sharpening failed (curvature_guided): {error}")
|
||||
|
||||
if hasattr(trimesh, "metadata") and trimesh.metadata:
|
||||
sharpened.metadata = trimesh.metadata.copy()
|
||||
sharpened.metadata["sharpening"] = {
|
||||
"algorithm": "curvature_guided", "device": str(dev),
|
||||
"iterations": iterations, "sigma_s": sigma_s,
|
||||
"sigma_r_degrees": sigma_r_degrees, "anchor_weight": anchor_weight,
|
||||
}
|
||||
|
||||
disp = np.linalg.norm(np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1)
|
||||
info = (f"Sharpen Mesh Results (curvature_guided, device={dev}):\n\n"
|
||||
f"Iterations: {iterations}\nSigma S: {sigma_s}\nSigma R: {sigma_r_degrees} deg\n"
|
||||
f"Anchor weight: {anchor_weight}\nDirection blend: {direction_blend}\n"
|
||||
f"Time: {elapsed:.2f}s\n\n"
|
||||
f"Vertices: {iv:,} (unchanged)\nFaces: {ifc:,} (unchanged)\n\n"
|
||||
f"Displacement:\n Average: {float(np.mean(disp)):.6f}\n Maximum: {float(np.max(disp)):.6f}\n")
|
||||
log.info("[curvature_guided] device=%s avg_disp=%.6f", dev, float(np.mean(disp)))
|
||||
return io.NodeOutput(sharpened, info, ui={"text": [info]})
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"GeomPackSharpen_CurvatureGuided": SharpenCurvatureGuidedNode}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_CurvatureGuided": "Sharpen Curvature Guided (backend)"}
|
||||
Reference in New Issue
Block a user