Add files via upload

This commit is contained in:
mikemojen
2026-03-30 11:31:11 +07:00
committed by GitHub
parent 884af2e9d2
commit 33882fe6f5
+420
View File
@@ -0,0 +1,420 @@
"""
ComfyUI Custom Node: Contour Gap Closer
Detects broken contour lines in binary/sketch images and connects
nearby endpoints to produce closed, continuous contours.
Algorithm:
1. Binarize the input image (contour lines = foreground).
2. Skeletonize to single-pixel-wide lines.
3. Detect endpoints (pixels with exactly 1 neighbour in the skeleton).
4. For every endpoint, find the nearest other endpoint within a
configurable max-gap distance and draw a connecting line.
5. Optionally apply a second skeletonize pass so the output stays
single-pixel-wide.
6. Optionally apply morphological closing first to heal very small
micro-gaps before the endpoint search.
"""
import numpy as np
import torch
from scipy import ndimage
from scipy.spatial import KDTree
from skimage.morphology import skeletonize, disk, closing
from skimage.draw import line as sk_line
# ──────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────
def _binarize(gray: np.ndarray, threshold: float, invert: bool) -> np.ndarray:
"""Return a bool array where True = contour foreground."""
if invert:
return gray < threshold
return gray > threshold
def _find_endpoints(skel: np.ndarray) -> np.ndarray:
"""
Find endpoints in a skeletonised binary image.
An endpoint is a foreground pixel with exactly 1 foreground neighbour
in its 8-connected neighbourhood.
Returns an (N, 2) int array of (row, col) coordinates.
"""
# Count neighbours using convolution
kernel = np.array([[1, 1, 1],
[1, 0, 1],
[1, 1, 1]], dtype=np.uint8)
neighbour_count = ndimage.convolve(skel.astype(np.uint8), kernel,
mode='constant', cval=0)
endpoints = np.argwhere(skel & (neighbour_count == 1))
return endpoints
def _direction_at_endpoint(skel: np.ndarray, pt: np.ndarray,
look_ahead: int = 6) -> np.ndarray:
"""
Estimate the tangent direction of the contour at an endpoint by
walking `look_ahead` pixels along the skeleton from `pt`.
Returns a unit vector (dr, dc).
"""
r, c = int(pt[0]), int(pt[1])
h, w = skel.shape
visited = set()
visited.add((r, c))
path = [(r, c)]
for _ in range(look_ahead):
found_next = False
cr, cc = path[-1]
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
if dr == 0 and dc == 0:
continue
nr, nc = cr + dr, cc + dc
if 0 <= nr < h and 0 <= nc < w:
if skel[nr, nc] and (nr, nc) not in visited:
visited.add((nr, nc))
path.append((nr, nc))
found_next = True
break
if found_next:
break
if not found_next:
break
if len(path) < 2:
return np.array([0.0, 0.0])
# Direction vector from last path point *towards* the endpoint
far = path[-1]
direction = np.array([pt[0] - far[0], pt[1] - far[1]], dtype=np.float64)
norm = np.linalg.norm(direction)
if norm > 0:
direction /= norm
return direction
def _connect_endpoints(skel: np.ndarray, max_gap: int,
direction_weight: float = 0.5,
look_ahead: int = 6) -> np.ndarray:
"""
Connect pairs of nearby endpoints in `skel`.
For each endpoint we:
• find the nearest unmatched endpoint within `max_gap` pixels
• optionally bias toward endpoints whose tangent directions
are roughly collinear (controlled by `direction_weight`)
• draw a straight line between the matched pair
Returns a copy of `skel` with the new connections drawn.
"""
result = skel.copy()
endpoints = _find_endpoints(skel)
if len(endpoints) < 2:
return result
# Pre-compute tangent directions
directions = np.array([_direction_at_endpoint(skel, ep, look_ahead)
for ep in endpoints])
# Build KD-tree for fast spatial lookup
tree = KDTree(endpoints)
used = set()
pairs = []
# Sort candidate pairs by distance so we greedily match closest first
all_dists = tree.sparse_distance_matrix(tree, max_distance=max_gap,
output_type='coo_matrix')
# Extract (i, j, dist) triples where i < j
candidates = []
for i, j, d in zip(all_dists.row, all_dists.col, all_dists.data):
if i < j:
candidates.append((d, i, j))
candidates.sort()
for dist, i, j in candidates:
if i in used or j in used:
continue
# Direction compatibility: the two tangent vectors should point
# roughly toward each other. Compute a score in [0, 1].
d_i = directions[i]
d_j = directions[j]
if np.linalg.norm(d_i) > 0 and np.linalg.norm(d_j) > 0:
# Vector from i -> j
link = endpoints[j] - endpoints[i]
link_norm = np.linalg.norm(link)
if link_norm > 0:
link_unit = link / link_norm
# endpoint i's tangent should point toward j (dot > 0)
# endpoint j's tangent should point toward i (dot < 0 with link)
score_i = np.dot(d_i, link_unit)
score_j = np.dot(d_j, -link_unit)
compat = (score_i + score_j) / 2.0 # range [-1, 1]
else:
compat = 1.0
else:
compat = 0.0
# Accept the pair if direction compatibility is above threshold
# (lower direction_weight = accept more aggressively)
threshold = -1.0 + direction_weight * 1.0 # maps [0,1] -> [-1, 0]
if compat < threshold:
continue
used.add(i)
used.add(j)
pairs.append((i, j))
# Draw connecting lines
for i, j in pairs:
r0, c0 = int(endpoints[i][0]), int(endpoints[i][1])
r1, c1 = int(endpoints[j][0]), int(endpoints[j][1])
rr, cc = sk_line(r0, c0, r1, c1)
result[rr, cc] = True
return result
# ──────────────────────────────────────────────
# ComfyUI Node
# ──────────────────────────────────────────────
class ContourGapCloser:
"""
Joins broken contour lines to produce closed, continuous paths.
Inputs
------
image : IMAGE
The sketch / contour image (white background, dark lines or vice-versa).
Parameters
----------
max_gap : INT
Maximum pixel distance between two endpoints to consider joining.
threshold : FLOAT
Binarisation threshold (0-1). Pixels darker/lighter than this
are treated as contour foreground depending on `invert`.
invert : BOOL
If True, dark pixels are foreground (typical for black-on-white
sketches). If False, bright pixels are foreground.
morph_close_radius : INT
Radius of a disk structuring element for an initial morphological
closing pass. Set to 0 to skip.
re_skeletonize : BOOL
If True, skeletonize the result after gap-filling so lines stay
single-pixel-wide.
direction_weight : FLOAT
How strongly to prefer connecting endpoints whose tangent
directions are collinear (0 = ignore direction, 1 = strict).
iterations : INT
Number of detect-and-connect iterations. More iterations can
close chains of small gaps.
output_mode : ["skeleton", "dilated", "original_thickness"]
How to render the output contour lines.
dilation_radius : INT
Line thickness (radius) when output_mode is "dilated".
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"max_gap": ("INT", {
"default": 20,
"min": 1,
"max": 200,
"step": 1,
"display": "slider",
"tooltip": "Maximum pixel distance between endpoints to bridge",
}),
"threshold": ("FLOAT", {
"default": 0.5,
"min": 0.01,
"max": 0.99,
"step": 0.01,
"display": "slider",
"tooltip": "Binarization threshold (0-1)",
}),
"invert": ("BOOLEAN", {
"default": True,
"tooltip": "True = dark lines on light background",
}),
"morph_close_radius": ("INT", {
"default": 2,
"min": 0,
"max": 20,
"step": 1,
"display": "slider",
"tooltip": "Morphological closing radius to heal micro-gaps first (0=skip)",
}),
"re_skeletonize": ("BOOLEAN", {
"default": False,
"tooltip": "Skeletonize the output to single-pixel lines",
}),
"direction_weight": ("FLOAT", {
"default": 0.3,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"display": "slider",
"tooltip": "Direction matching strictness (0=any, 1=strict collinear)",
}),
"iterations": ("INT", {
"default": 3,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "Repeat endpoint-detection + connection N times",
}),
"output_mode": (["original_thickness", "skeleton", "dilated"],),
"dilation_radius": ("INT", {
"default": 1,
"min": 1,
"max": 10,
"step": 1,
"display": "slider",
"tooltip": "Line thickness when output_mode='dilated'",
}),
},
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "contour_mask")
FUNCTION = "close_gaps"
CATEGORY = "image/contour"
DESCRIPTION = ("Detects broken contour lines and joins nearby endpoints "
"to produce closed, continuous paths.")
def close_gaps(self, image: torch.Tensor, max_gap: int,
threshold: float, invert: bool,
morph_close_radius: int, re_skeletonize: bool,
direction_weight: float, iterations: int,
output_mode: str, dilation_radius: int):
batch_out_images = []
batch_out_masks = []
for b in range(image.shape[0]):
# ── 1. To numpy grayscale ──
img_np = image[b].cpu().numpy() # (H, W, C) float32 0-1
if img_np.ndim == 3:
gray = np.mean(img_np, axis=2)
else:
gray = img_np.copy()
# ── 2. Binarize ──
binary = _binarize(gray, threshold, invert)
# ── 3. Optional morphological closing for micro-gaps ──
if morph_close_radius > 0:
selem = disk(morph_close_radius)
binary = closing(binary, selem)
# ── 4. Skeletonize ──
skel = skeletonize(binary)
# ── 5. Iterative endpoint connection ──
for _ in range(iterations):
skel = _connect_endpoints(skel, max_gap,
direction_weight=direction_weight)
# ── 6. Optional re-skeletonize ──
if re_skeletonize:
skel = skeletonize(skel)
# ── 7. Build output ──
if output_mode == "skeleton":
out_binary = skel
elif output_mode == "dilated":
selem = disk(dilation_radius)
out_binary = ndimage.binary_dilation(skel, structure=selem)
else: # original_thickness
# Combine original binary with the new skeleton connections
out_binary = binary | skel
# Convert to float image (dark lines on white bg if inverted)
out_float = np.where(out_binary, 0.0, 1.0) if invert else \
np.where(out_binary, 1.0, 0.0)
out_rgb = np.stack([out_float] * 3, axis=-1).astype(np.float32)
mask_float = out_binary.astype(np.float32)
batch_out_images.append(torch.from_numpy(out_rgb))
batch_out_masks.append(torch.from_numpy(mask_float))
out_image = torch.stack(batch_out_images, dim=0)
out_mask = torch.stack(batch_out_masks, dim=0)
return (out_image, out_mask)
# ──────────────────────────────────────────────
# Debug / Visualisation Node
# ──────────────────────────────────────────────
class ContourEndpointVisualizer:
"""
Diagnostic node: highlights detected endpoints on the image so you
can tune parameters before running the gap closer.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"threshold": ("FLOAT", {
"default": 0.5, "min": 0.01, "max": 0.99, "step": 0.01,
}),
"invert": ("BOOLEAN", {"default": True}),
"morph_close_radius": ("INT", {
"default": 2, "min": 0, "max": 20, "step": 1,
}),
"endpoint_radius": ("INT", {
"default": 5, "min": 1, "max": 20, "step": 1,
"tooltip": "Radius of the circles drawn at each endpoint",
}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("debug_image",)
FUNCTION = "visualize"
CATEGORY = "image/contour"
DESCRIPTION = "Shows detected contour endpoints as red circles for debugging."
def visualize(self, image, threshold, invert, morph_close_radius,
endpoint_radius):
from skimage.draw import disk as sk_disk
batch_out = []
for b in range(image.shape[0]):
img_np = image[b].cpu().numpy().copy()
if img_np.ndim == 2:
img_np = np.stack([img_np] * 3, axis=-1)
gray = np.mean(img_np, axis=2)
binary = _binarize(gray, threshold, invert)
if morph_close_radius > 0:
selem = disk(morph_close_radius)
binary = closing(binary, selem)
skel = skeletonize(binary)
endpoints = _find_endpoints(skel)
# Draw red circles at endpoints
h, w = img_np.shape[:2]
for ep in endpoints:
rr, cc = sk_disk((int(ep[0]), int(ep[1])),
endpoint_radius, shape=(h, w))
img_np[rr, cc] = [1.0, 0.0, 0.0] # red
batch_out.append(torch.from_numpy(img_np.astype(np.float32)))
return (torch.stack(batch_out, dim=0),)