""" ComfyUI Custom Nodes: Laser Cutter Path Tracer & Hole Counter ============================================================== Detects ALL internal contours (holes, slots, cutouts, notches, complex shapes) in a B&W laser-cutting diagram and traces the optimal laser cutter head path between them. """ import math import numpy as np import cv2 try: import torch except ImportError: torch = None # ────────────────────────────────────────────────────────────────────── # Geometry helpers # ────────────────────────────────────────────────────────────────────── def contour_centroid(contour): """Return (cx, cy) of a contour using image moments.""" M = cv2.moments(contour) if M["m00"] == 0: pts = contour.reshape(-1, 2) return float(pts[:, 0].mean()), float(pts[:, 1].mean()) return float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]) def contour_nearest_edge_point(contour, ref_point): """Return the point on `contour` closest to `ref_point`.""" pts = contour.reshape(-1, 2).astype(np.float64) ref = np.array(ref_point, dtype=np.float64) dists = np.linalg.norm(pts - ref, axis=1) return tuple(pts[np.argmin(dists)].astype(int)) def euclidean(a, b): return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5 # ────────────────────────────────────────────────────────────────────── # Contour shape classification # ────────────────────────────────────────────────────────────────────── def classify_contour(contour): """ Classify a contour by its geometric properties. Returns dict with: type, circularity, aspect_ratio, solidity, vertex_count, bbox, area, perimeter. """ area = cv2.contourArea(contour) perimeter = cv2.arcLength(contour, True) bbox = cv2.boundingRect(contour) if perimeter < 1 or area < 1: return dict(type="line", circularity=0, aspect_ratio=1, solidity=0, vertex_count=len(contour), bbox=bbox, area=area, perimeter=perimeter) circularity = 4 * math.pi * area / (perimeter ** 2) rect = cv2.minAreaRect(contour) rw, rh = rect[1] aspect_ratio = max(rw, rh) / min(rw, rh) if min(rw, rh) > 0 else 1.0 hull = cv2.convexHull(contour) hull_area = cv2.contourArea(hull) solidity = area / hull_area if hull_area > 0 else 0 epsilon = 0.02 * perimeter approx = cv2.approxPolyDP(contour, epsilon, True) vertex_count = len(approx) # Classification if circularity > 0.75: shape_type = "hole" elif circularity > 0.55 and aspect_ratio < 1.6: shape_type = "hole" elif aspect_ratio > 2.5: shape_type = "slot" elif vertex_count == 3: shape_type = "triangle" elif vertex_count == 4 and solidity > 0.85: shape_type = "rectangle" elif vertex_count <= 8 and solidity > 0.80: shape_type = "polygon" elif solidity < 0.60: shape_type = "complex" else: shape_type = "cutout" return dict(type=shape_type, circularity=circularity, aspect_ratio=aspect_ratio, solidity=solidity, vertex_count=vertex_count, bbox=bbox, area=area, perimeter=perimeter) # ────────────────────────────────────────────────────────────────────── # Internal feature data structure # ────────────────────────────────────────────────────────────────────── class InternalFeature: """Represents a single detected internal feature.""" __slots__ = ("center", "contour", "classification", "bbox", "area") def __init__(self, center, contour, classification): self.center = center self.contour = contour self.classification = classification self.bbox = classification["bbox"] self.area = classification["area"] @property def type(self): return self.classification["type"] def info_str(self): c = self.classification return ( f"({self.center[0]:.0f}, {self.center[1]:.0f}) " f"type={self.type}, area={c['area']:.0f}, " f"circ={c['circularity']:.2f}, AR={c['aspect_ratio']:.1f}, " f"solid={c['solidity']:.2f}, verts={c['vertex_count']}" ) # ────────────────────────────────────────────────────────────────────── # TSP solvers # ────────────────────────────────────────────────────────────────────── def nearest_neighbor_open(points, start_idx, end_idx): n = len(points) if n <= 2: return list(range(n)) visited = [False] * n path = [start_idx] visited[start_idx] = True visited[end_idx] = True current = start_idx for _ in range(n - 2): best_dist, best_idx = float("inf"), -1 for j in range(n): if not visited[j]: d = euclidean(points[current], points[j]) if d < best_dist: best_dist, best_idx = d, j if best_idx == -1: break visited[best_idx] = True path.append(best_idx) current = best_idx path.append(end_idx) return path def two_opt_improve(path, points, max_iterations=1000): def path_length(p): return sum(euclidean(points[p[i]], points[p[i+1]]) for i in range(len(p)-1)) best_dist = path_length(path) improved = True iteration = 0 while improved and iteration < max_iterations: improved = False iteration += 1 for i in range(1, len(path) - 2): for j in range(i + 1, len(path) - 1): new_path = path[:i] + path[i:j+1][::-1] + path[j+1:] new_dist = path_length(new_path) if new_dist < best_dist - 1e-6: path, best_dist, improved = new_path, new_dist, True break if improved: break return path def solve_tsp(points, start_idx, end_idx): if len(points) <= 1: return list(range(len(points))) if len(points) == 2: return [start_idx, end_idx] path = nearest_neighbor_open(points, start_idx, end_idx) path = two_opt_improve(path, points) return path # ────────────────────────────────────────────────────────────────────── # Binarization helper # ────────────────────────────────────────────────────────────────────── def _binarize(gray_img, binary_threshold, invert_image, use_adaptive_threshold, adaptive_block_size, adaptive_c): """Return binary image (white bg, black lines).""" if use_adaptive_threshold: block = adaptive_block_size | 1 # ensure odd binary = cv2.adaptiveThreshold( gray_img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, block, adaptive_c) else: _, binary = cv2.threshold(gray_img, binary_threshold, 255, cv2.THRESH_BINARY) if invert_image: binary = cv2.bitwise_not(binary) return binary def _find_external_boundary(contours, areas): if not contours: return -1, None, 0 ext_idx = int(np.argmax(areas)) return ext_idx, contours[ext_idx], areas[ext_idx] def _find_all_pieces(contours, areas, hierarchy, img_area, min_piece_pct=0.002): """ Identify all separate pieces (external boundaries) in the image. A piece is a contour that: - Has area > min_piece_pct of the total image area - Is NOT fully contained inside another larger piece (i.e. it's not a hole/feature of a bigger piece) Returns list of (contour_index, contour, area) tuples. """ if not contours: return [] min_piece_area = img_area * min_piece_pct h = hierarchy[0] if hierarchy is not None else None # Gather candidate pieces sorted by area descending candidates = [] for i, c in enumerate(contours): a = areas[i] if a >= min_piece_area: candidates.append((i, c, a)) candidates.sort(key=lambda x: -x[2]) if not candidates: return [] # Filter: reject contours whose centroid is inside a larger contour # AND whose area is less than half of that larger contour (= a feature). # Keep contours that are independent pieces or nearly as large as # their parent (= outer/inner stroke edge of the same piece). pieces = [] piece_contours = [] # contours already accepted as pieces for idx, c, a in candidates: cx, cy = contour_centroid(c) is_sub_feature = False for p_idx, p_c, p_a in piece_contours: if a > p_a * 0.85: # Nearly same size as parent — likely inner/outer stroke # edge of the same piece. Skip (dedup will handle). continue inside = cv2.pointPolygonTest(p_c, (cx, cy), False) if inside >= 0 and a < p_a * 0.5: is_sub_feature = True break if not is_sub_feature: pieces.append((idx, c, a)) piece_contours.append((idx, c, a)) return pieces # ────────────────────────────────────────────────────────────────────── # Legacy: simple contour detection (used by HoleCounter pass 3) # ────────────────────────────────────────────────────────────────────── def find_internal_contours(binary_img, min_area=30, max_area_ratio=0.5): contours, hierarchy = cv2.findContours( binary_img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None, [] areas = [cv2.contourArea(c) for c in contours] if max(areas) == 0: return None, [] ext_idx = int(np.argmax(areas)) external_contour = contours[ext_idx] ext_area = areas[ext_idx] candidates = [] for i, c in enumerate(contours): if i == ext_idx: continue a = areas[i] if a < min_area or a > ext_area * max_area_ratio: continue cx, cy = contour_centroid(c) if cv2.pointPolygonTest(external_contour, (cx, cy), False) >= 0: candidates.append((c, cx, cy, a)) candidates.sort(key=lambda x: -x[3]) internal, used = [], [] for c, cx, cy, a in candidates: if not any(((cx-ux)**2+(cy-uy)**2)**0.5 < 15 for ux, uy in used): internal.append(c) used.append((cx, cy)) return external_contour, internal # ────────────────────────────────────────────────────────────────────── # Pass 1: Contour-based detection (finds ANY drawn shape) # ────────────────────────────────────────────────────────────────────── def _contour_based_detect(binary, min_area, max_area_ratio): """ Hierarchy-aware contour detection that finds ALL internal features across ALL pieces in the image. """ lines_mask = cv2.bitwise_not(binary) contours, hierarchy = cv2.findContours( lines_mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) if not contours or hierarchy is None: return [], [] areas = [cv2.contourArea(c) for c in contours] h, w = binary.shape[:2] img_area = h * w # Find all pieces (external boundaries) pieces = _find_all_pieces(contours, areas, hierarchy, img_area) if not pieces: return [], [] piece_indices = set(idx for idx, _, _ in pieces) features = [] for i, c in enumerate(contours): if i in piece_indices: continue a = areas[i] if a < min_area: continue cx, cy = contour_centroid(c) # Check if this contour is inside ANY piece parent_piece = None for p_idx, p_c, p_a in pieces: if a > p_a * max_area_ratio: continue if cv2.pointPolygonTest(p_c, (cx, cy), False) >= 0: parent_piece = (p_idx, p_c, p_a) break if parent_piece is None: continue p_idx, p_c, p_a = parent_piece # Boundary-proximity filter (shape-aware) cls = classify_contour(c) pts = c.reshape(-1, 2).astype(np.float64) if len(pts) > 0: dists = [abs(cv2.pointPolygonTest(p_c, (float(p[0]), float(p[1])), True)) for p in pts[::max(1, len(pts)//20)]] median_dist = float(np.median(dists)) if median_dist < 15: if cls["circularity"] < 0.65: continue if cls["solidity"] > 0.95 and a > p_a * 0.01: continue features.append(InternalFeature((cx, cy), c, cls)) piece_contours = [p_c for _, p_c, _ in pieces] return features, piece_contours # ────────────────────────────────────────────────────────────────────── # Pass 2/3: Flood-fill detection (finds enclosed white pockets) # ────────────────────────────────────────────────────────────────────── def _flood_fill_detect_features(binary, min_area, max_area_ratio, morph_close_size=0, morph_dilate_size=0): h, w = binary.shape[:2] img_area = h * w lines_mask = cv2.bitwise_not(binary) if morph_close_size > 0: k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (morph_close_size, morph_close_size)) lines_mask = cv2.morphologyEx(lines_mask, cv2.MORPH_CLOSE, k) if morph_dilate_size > 0: k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (morph_dilate_size, morph_dilate_size)) lines_mask = cv2.dilate(lines_mask, k, iterations=1) repaired = cv2.bitwise_not(lines_mask) contours_all, hierarchy_all = cv2.findContours( lines_mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) if not contours_all: return [] areas_all = [cv2.contourArea(c) for c in contours_all] pieces = _find_all_pieces(contours_all, areas_all, hierarchy_all, img_area) if not pieces: return [] flood = repaired.copy() flood_mask = np.zeros((h + 2, w + 2), np.uint8) cv2.floodFill(flood, flood_mask, (0, 0), 0) num_labels, labels, stats, centroids_cc = cv2.connectedComponentsWithStats( flood, connectivity=8) features = [] for lid in range(1, num_labels): area = stats[lid, cv2.CC_STAT_AREA] if area < min_area: continue cx, cy = float(centroids_cc[lid][0]), float(centroids_cc[lid][1]) # Find parent piece parent_piece = None for p_idx, p_c, p_a in pieces: if area > p_a * max_area_ratio: continue if cv2.pointPolygonTest(p_c, (cx, cy), False) >= 0: parent_piece = (p_idx, p_c, p_a) break if parent_piece is None: continue p_idx, p_c, p_a = parent_piece dist_to_boundary = abs(cv2.pointPolygonTest(p_c, (cx, cy), True)) cmask = (labels == lid).astype(np.uint8) * 255 cc, _ = cv2.findContours(cmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cont = cc[0] if cc else None cls = classify_contour(cont) if cont is not None else dict( type="hole", circularity=0, aspect_ratio=1, solidity=0, vertex_count=0, bbox=(int(cx), int(cy), 1, 1), area=area, perimeter=0) if dist_to_boundary < 20: if cls["circularity"] < 0.65: continue if cls["solidity"] > 0.95 and area > p_a * 0.01: continue features.append(InternalFeature((cx, cy), cont, cls)) return features # ────────────────────────────────────────────────────────────────────── # Pass 4: Open contour detection (arcs, grooves, engravings) # ────────────────────────────────────────────────────────────────────── def _open_contour_detect(binary, piece_contours, min_area, max_area_ratio): """ Detect open contours (arcs, grooves) inside any piece. """ lines_mask = cv2.bitwise_not(binary) num_labels, labels, stats, centroids_cc = cv2.connectedComponentsWithStats( lines_mask, connectivity=8) if num_labels < 2: return [] # Skip the largest line-pixel component (external boundary) all_areas = [stats[lid, cv2.CC_STAT_AREA] for lid in range(1, num_labels)] max_line_comp_label = int(np.argmax(all_areas)) + 1 features = [] for lid in range(1, num_labels): if lid == max_line_comp_label: continue area = stats[lid, cv2.CC_STAT_AREA] if area < min_area: continue cx, cy = float(centroids_cc[lid][0]), float(centroids_cc[lid][1]) # Find parent piece parent_piece = None for p_c in piece_contours: p_a = cv2.contourArea(p_c) if area > p_a * max_area_ratio: continue if cv2.pointPolygonTest(p_c, (cx, cy), False) >= 0: parent_piece = p_c break if parent_piece is None: continue dist_to_boundary = abs(cv2.pointPolygonTest(parent_piece, (cx, cy), True)) if dist_to_boundary < 20: cmask_pre = (labels == lid).astype(np.uint8) * 255 cc_pre, _ = cv2.findContours(cmask_pre, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cont_pre = cc_pre[0] if cc_pre else None if cont_pre is not None: cls_pre = classify_contour(cont_pre) if cls_pre["circularity"] < 0.65: continue else: continue cmask = (labels == lid).astype(np.uint8) * 255 cc, _ = cv2.findContours(cmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cont = cc[0] if cc else None if cont is not None: cls = classify_contour(cont) peri = cv2.arcLength(cont, True) if peri > 0 and area / peri < 2.0: cls["type"] = "arc" else: cls = dict(type="arc", circularity=0, aspect_ratio=1, solidity=0, vertex_count=0, bbox=(int(cx), int(cy), 1, 1), area=area, perimeter=0) features.append(InternalFeature((cx, cy), cont, cls)) return features # ────────────────────────────────────────────────────────────────────── # Deduplication # ────────────────────────────────────────────────────────────────────── def _deduplicate_features(features, dedup_radius): if dedup_radius <= 0 or not features: return features features.sort(key=lambda f: -f.area) deduped, used = [], [] for f in features: cx, cy = f.center if not any(((cx-ux)**2+(cy-uy)**2)**0.5 < dedup_radius for ux, uy in used): deduped.append(f) used.append((cx, cy)) return deduped # ────────────────────────────────────────────────────────────────────── # Master detection: robust_find_features # ────────────────────────────────────────────────────────────────────── def robust_find_features( gray_img, binary_threshold, invert_image, morph_close_size, morph_dilate_size, min_feature_area, max_feature_area_pct, dedup_radius, use_adaptive_threshold, adaptive_block_size, adaptive_c, detect_open_contours=True, ): """ Robustly detect ALL internal features — holes, slots, cutouts, polygons, arcs, complex shapes — inside the external boundary. Four-pass strategy: Pass 1 — Contour-based: any drawn shape inside the boundary. Pass 2 — Flood-fill clean: enclosed white pockets. Pass 3 — Flood-fill + morph: broken/gapped contours repaired. Pass 4 — Open contour: arcs, grooves, engravings. Returns: list of InternalFeature objects. """ binary = _binarize(gray_img, binary_threshold, invert_image, use_adaptive_threshold, adaptive_block_size, adaptive_c) # Pass 1: contour-based features1, piece_contours = _contour_based_detect( binary, min_feature_area, max_feature_area_pct) # Pass 2: flood-fill clean features2 = _flood_fill_detect_features( binary, min_feature_area, max_feature_area_pct) # Pass 3: flood-fill with morph repair features3 = _flood_fill_detect_features( binary, min_feature_area, max_feature_area_pct, morph_close_size, morph_dilate_size) # Pass 4: open contours features4 = [] if detect_open_contours and piece_contours: features4 = _open_contour_detect( binary, piece_contours, min_feature_area, max_feature_area_pct) all_features = features1 + features2 + features3 + features4 return _deduplicate_features(all_features, dedup_radius) # ────────────────────────────────────────────────────────────────────── # Legacy wrapper: robust_find_holes (for HoleCounterNode) # ────────────────────────────────────────────────────────────────────── def robust_find_holes( gray_img, binary_threshold, invert_image, morph_close_size, morph_dilate_size, min_hole_area, max_hole_area_pct, dedup_radius, use_adaptive_threshold, adaptive_block_size, adaptive_c, ): features = robust_find_features( gray_img=gray_img, binary_threshold=binary_threshold, invert_image=invert_image, morph_close_size=morph_close_size, morph_dilate_size=morph_dilate_size, min_feature_area=min_hole_area, max_feature_area_pct=max_hole_area_pct, dedup_radius=dedup_radius, use_adaptive_threshold=use_adaptive_threshold, adaptive_block_size=adaptive_block_size, adaptive_c=adaptive_c, detect_open_contours=False, ) centers = [f.center for f in features] contours = [f.contour for f in features] debug = _binarize(gray_img, binary_threshold, invert_image, use_adaptive_threshold, adaptive_block_size, adaptive_c) return centers, contours, debug # ────────────────────────────────────────────────────────────────────── # Drawing # ────────────────────────────────────────────────────────────────────── FEATURE_COLORS = { "hole": (180, 120, 0), "slot": (0, 160, 160), "rectangle": (0, 140, 0), "triangle": (0, 100, 200), "polygon": (160, 80, 160), "complex": (0, 80, 200), "cutout": (100, 140, 0), "arc": (100, 100, 100), "line": (100, 100, 100), } def draw_path(image, ordered_centroids, line_color=(80,80,80), start_color=(0,180,0), end_color=(0,0,220), point_color=(60,60,60), line_thickness=2, point_radius=6): out = image.copy() if len(out.shape) == 2: out = cv2.cvtColor(out, cv2.COLOR_GRAY2BGR) n = len(ordered_centroids) if n == 0: return out for i in range(n - 1): pt1 = (int(ordered_centroids[i][0]), int(ordered_centroids[i][1])) pt2 = (int(ordered_centroids[i+1][0]), int(ordered_centroids[i+1][1])) cv2.line(out, pt1, pt2, line_color, line_thickness, cv2.LINE_AA) for i, (cx, cy) in enumerate(ordered_centroids): pt = (int(cx), int(cy)) if i == 0: cv2.circle(out, pt, point_radius+2, start_color, -1, cv2.LINE_AA) elif i == n - 1: cv2.circle(out, pt, point_radius+2, end_color, -1, cv2.LINE_AA) else: cv2.circle(out, pt, point_radius, point_color, -1, cv2.LINE_AA) return out def draw_features_path(image, ordered_features, start_point, end_point, line_color=(80,80,80), start_color=(0,180,0), end_color=(0,0,220), line_thickness=2, point_radius=6, show_contours=True, show_labels=True): """Draw path with feature-type-aware color-coded annotations.""" out = image.copy() if len(out.shape) == 2: out = cv2.cvtColor(out, cv2.COLOR_GRAY2BGR) all_pts = [start_point] + [f.center for f in ordered_features] + [end_point] for i in range(len(all_pts) - 1): p1 = (int(all_pts[i][0]), int(all_pts[i][1])) p2 = (int(all_pts[i+1][0]), int(all_pts[i+1][1])) cv2.line(out, p1, p2, line_color, line_thickness, cv2.LINE_AA) cv2.circle(out, (int(start_point[0]), int(start_point[1])), point_radius+2, start_color, -1, cv2.LINE_AA) cv2.circle(out, (int(end_point[0]), int(end_point[1])), point_radius+2, end_color, -1, cv2.LINE_AA) font = cv2.FONT_HERSHEY_SIMPLEX for i, feat in enumerate(ordered_features): color = FEATURE_COLORS.get(feat.type, (100, 100, 100)) pt = (int(feat.center[0]), int(feat.center[1])) if show_contours and feat.contour is not None: cv2.drawContours(out, [feat.contour], -1, color, 2, cv2.LINE_AA) cv2.circle(out, pt, point_radius, color, -1, cv2.LINE_AA) cv2.circle(out, pt, point_radius, (255, 255, 255), 1, cv2.LINE_AA) if show_labels: label = f"{i+1}:{feat.type}" tsz = cv2.getTextSize(label, font, 0.38, 1)[0] tx = int(feat.center[0] - tsz[0] / 2) ty = int(feat.center[1] - point_radius - 6) cv2.rectangle(out, (tx-2, ty-tsz[1]-2), (tx+tsz[0]+2, ty+4), (255, 255, 255), -1) cv2.putText(out, label, (tx, ty), font, 0.38, color, 1, cv2.LINE_AA) return out def draw_hole_annotations(image, hole_centers, hole_contours, point_radius=8): out = image.copy() if len(out.shape) == 2: out = cv2.cvtColor(out, cv2.COLOR_GRAY2BGR) if not hole_centers: return out for cont in hole_contours: if cont is not None: cv2.drawContours(out, [cont], -1, (0, 180, 0), 2, cv2.LINE_AA) font = cv2.FONT_HERSHEY_SIMPLEX for i, (cx, cy) in enumerate(hole_centers): pt = (int(cx), int(cy)) cv2.circle(out, pt, point_radius, (0, 0, 220), -1, cv2.LINE_AA) cv2.circle(out, pt, point_radius, (255, 255, 255), 1, cv2.LINE_AA) label = str(i + 1) tsz = cv2.getTextSize(label, font, 0.45, 1)[0] tx, ty = int(cx - tsz[0]/2), int(cy - point_radius - 6) cv2.rectangle(out, (tx-2, ty-tsz[1]-2), (tx+tsz[0]+2, ty+4), (255, 255, 255), -1) cv2.putText(out, label, (tx, ty), font, 0.45, (0, 0, 180), 1, cv2.LINE_AA) return out # ────────────────────────────────────────────────────────────────────── # ComfyUI Node — Laser Path Tracer # ────────────────────────────────────────────────────────────────────── class LaserPathTracerNode: """ Traces the optimal laser cutter path between ALL internal features (holes, slots, cutouts, polygons, arcs, complex shapes). Start = bottom-right, End = bottom-left. Bypasses gracefully if no features are detected. """ @classmethod def INPUT_TYPES(cls): return {"required": { "image": ("IMAGE",), "binary_threshold": ("INT", {"default": 128, "min": 0, "max": 255, "step": 1}), "min_feature_area": ("INT", {"default": 30, "min": 1, "max": 50000, "step": 1, "tooltip": "Minimum contour area (px2) to count as a feature."}), "max_feature_area_pct": ("FLOAT", {"default": 0.4, "min": 0.01, "max": 0.99, "step": 0.01, "tooltip": "Max feature area as fraction of external boundary."}), "morph_close_size": ("INT", {"default": 5, "min": 0, "max": 31, "step": 2, "tooltip": "Morphological close kernel. Bridges gaps. 0=off."}), "morph_dilate_size": ("INT", {"default": 0, "min": 0, "max": 15, "step": 1, "tooltip": "Dilation kernel. Thickens thin strokes. 0=off."}), "dedup_radius": ("INT", {"default": 15, "min": 0, "max": 100, "step": 1, "tooltip": "Merge features whose centers are within this many px."}), "invert_image": ("BOOLEAN", {"default": False}), "use_adaptive_threshold": ("BOOLEAN", {"default": False, "tooltip": "Adaptive thresholding for noisy/low-contrast images."}), "adaptive_block_size": ("INT", {"default": 51, "min": 3, "max": 201, "step": 2}), "adaptive_c": ("INT", {"default": 10, "min": -30, "max": 60, "step": 1}), "detect_open_contours": ("BOOLEAN", {"default": True, "tooltip": "Also detect arcs, grooves, engravings (open line segments)."}), }} RETURN_TYPES = ("IMAGE", "INT", "STRING") RETURN_NAMES = ("traced_image", "feature_count", "path_info") FUNCTION = "trace_path" CATEGORY = "Laser/Path" def trace_path(self, image, binary_threshold, min_feature_area, max_feature_area_pct, morph_close_size, morph_dilate_size, dedup_radius, invert_image, use_adaptive_threshold, adaptive_block_size, adaptive_c, detect_open_contours): img_np = image[0].cpu().numpy() img_uint8 = (img_np * 255).clip(0, 255).astype(np.uint8) if img_uint8.shape[2] == 4: img_bgr = cv2.cvtColor(img_uint8, cv2.COLOR_RGBA2BGR) else: img_bgr = cv2.cvtColor(img_uint8, cv2.COLOR_RGB2BGR) gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) h, w = gray.shape[:2] # Detect all internal features features = robust_find_features( gray_img=gray, binary_threshold=binary_threshold, invert_image=invert_image, morph_close_size=morph_close_size, morph_dilate_size=morph_dilate_size, min_feature_area=min_feature_area, max_feature_area_pct=max_feature_area_pct, dedup_radius=dedup_radius, use_adaptive_threshold=use_adaptive_threshold, adaptive_block_size=adaptive_block_size, adaptive_c=adaptive_c, detect_open_contours=detect_open_contours) feat_count = len(features) # Bypass if nothing found if feat_count == 0: info = ( "STATUS: BYPASS -- no internal features detected.\n\n" "Image returned unchanged. Troubleshooting:\n" " - Toggle invert_image\n" " - Lower min_feature_area\n" " - Enable use_adaptive_threshold\n" " - Increase morph_close_size (7-11)\n" " - Set morph_dilate_size to 2-3") rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) t = torch.from_numpy(rgb.astype(np.float32) / 255.0).unsqueeze(0) return (t, 0, info) # TSP path margin_x, margin_y = int(w * 0.02), int(h * 0.02) start = (w - margin_x, h - margin_y) end = (margin_x, h - margin_y) centers = [f.center for f in features] all_pts = [start] + centers + [end] order = solve_tsp(all_pts, 0, len(all_pts) - 1) ordered_feats = [features[idx - 1] for idx in order if idx != 0 and idx != len(all_pts) - 1] ordered_pts = [all_pts[i] for i in order] # Draw path lines only — no annotations result_bgr = img_bgr.copy() all_centers = [start] + [f.center for f in ordered_feats] + [end] for i in range(len(all_centers) - 1): pt1 = (int(all_centers[i][0]), int(all_centers[i][1])) pt2 = (int(all_centers[i+1][0]), int(all_centers[i+1][1])) cv2.line(result_bgr, pt1, pt2, (0, 0, 0), 1, cv2.LINE_AA) rgb = cv2.cvtColor(result_bgr, cv2.COLOR_BGR2RGB) out = torch.from_numpy(rgb.astype(np.float32) / 255.0).unsqueeze(0) # Info string total_dist = sum(euclidean(ordered_pts[i], ordered_pts[i+1]) for i in range(len(ordered_pts)-1)) tc = {} for f in features: tc[f.type] = tc.get(f.type, 0) + 1 lines = [ f"STATUS: OK -- path traced successfully.", f"Features detected: {feat_count}", f"Breakdown: {', '.join(f'{v}x {k}' for k, v in sorted(tc.items()))}", f"Path points: {len(ordered_pts)} (incl. start & end)", f"Total path length: {total_dist:.1f} px", f"Start: ({start[0]:.0f}, {start[1]:.0f}) [bottom-right]", f"End: ({end[0]:.0f}, {end[1]:.0f}) [bottom-left]", "", "Visit order:", f" 0: ({start[0]:.0f}, {start[1]:.0f}) <- START", ] for i, feat in enumerate(ordered_feats): lines.append(f" {i+1}: {feat.info_str()}") lines.append(f" {len(ordered_feats)+1}: ({end[0]:.0f}, {end[1]:.0f}) <- END") return (out, feat_count, "\n".join(lines)) # ────────────────────────────────────────────────────────────────────── # ComfyUI Node — Hole Counter (backward-compatible) # ────────────────────────────────────────────────────────────────────── class HoleCounterNode: """Robustly counts internal holes in a B&W laser-cut diagram.""" @classmethod def INPUT_TYPES(cls): return {"required": { "image": ("IMAGE",), "binary_threshold": ("INT", {"default": 128, "min": 0, "max": 255, "step": 1}), "min_hole_area": ("INT", {"default": 50, "min": 1, "max": 50000, "step": 1}), "max_hole_area_pct": ("FLOAT", {"default": 0.4, "min": 0.01, "max": 0.99, "step": 0.01}), "morph_close_size": ("INT", {"default": 5, "min": 0, "max": 31, "step": 2}), "morph_dilate_size": ("INT", {"default": 0, "min": 0, "max": 15, "step": 1}), "dedup_radius": ("INT", {"default": 15, "min": 0, "max": 100, "step": 1}), "invert_image": ("BOOLEAN", {"default": False}), "use_adaptive_threshold": ("BOOLEAN", {"default": False}), "adaptive_block_size": ("INT", {"default": 51, "min": 3, "max": 201, "step": 2}), "adaptive_c": ("INT", {"default": 10, "min": -30, "max": 60, "step": 1}), "annotate_image": ("BOOLEAN", {"default": True}), "show_debug_binary": ("BOOLEAN", {"default": False}), }} RETURN_TYPES = ("IMAGE", "INT", "STRING") RETURN_NAMES = ("output_image", "hole_count", "hole_info") FUNCTION = "count_holes" CATEGORY = "Laser/Path" def count_holes(self, image, binary_threshold, min_hole_area, max_hole_area_pct, morph_close_size, morph_dilate_size, dedup_radius, invert_image, use_adaptive_threshold, adaptive_block_size, adaptive_c, annotate_image, show_debug_binary): img_np = image[0].cpu().numpy() img_uint8 = (img_np * 255).clip(0, 255).astype(np.uint8) if img_uint8.shape[2] == 4: img_bgr = cv2.cvtColor(img_uint8, cv2.COLOR_RGBA2BGR) else: img_bgr = cv2.cvtColor(img_uint8, cv2.COLOR_RGB2BGR) gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) centers, contours, debug = robust_find_holes( gray, binary_threshold, invert_image, morph_close_size, morph_dilate_size, min_hole_area, max_hole_area_pct, dedup_radius, use_adaptive_threshold, adaptive_block_size, adaptive_c) count = len(centers) if show_debug_binary: vis = cv2.cvtColor(debug, cv2.COLOR_GRAY2BGR) elif annotate_image: vis = draw_hole_annotations(img_bgr, centers, contours) else: vis = img_bgr.copy() rgb = cv2.cvtColor(vis, cv2.COLOR_BGR2RGB) out = torch.from_numpy(rgb.astype(np.float32) / 255.0).unsqueeze(0) indexed = sorted(enumerate(centers), key=lambda x: (x[1][1], x[1][0])) lines = [f"Holes detected: {count}", ""] for rank, (_, (cx, cy)) in enumerate(indexed): lines.append(f" #{rank+1}: ({cx:.0f}, {cy:.0f})") return (out, count, "\n".join(lines)) # ──────────────────────────────────────────────────────────────────────