256 lines
7.4 KiB
Python
256 lines
7.4 KiB
Python
# "Design-focused node suite for ComfyUI."
|
|
# Copyright 2026 Augment Studio
|
|
# Augmentstudio.app
|
|
|
|
"""
|
|
Smart shape-edge detection for construction lines.
|
|
Extracts meaningful structural lines, not boundary noise.
|
|
"""
|
|
import numpy as np
|
|
|
|
try:
|
|
from scipy import ndimage
|
|
HAS_SCIPY = True
|
|
except ImportError:
|
|
HAS_SCIPY = False
|
|
|
|
|
|
def compute_all_edges(mask_2d, edge_min=None, merge_tol=None):
|
|
"""
|
|
Main entry point — replaces the old boundary-tracing approach.
|
|
edge_min and merge_tol are kept for API compatibility but ignored.
|
|
"""
|
|
return compute_smart_edges(mask_2d)
|
|
|
|
|
|
def compute_smart_edges(mask, symmetry_threshold=0.85):
|
|
"""
|
|
Extract meaningful construction lines from a mask.
|
|
Returns structural lines, not boundary noise.
|
|
"""
|
|
binary = (mask > 0.5).astype(np.uint8)
|
|
|
|
if binary.sum() == 0:
|
|
return {
|
|
"h_lines": [],
|
|
"v_lines": [],
|
|
"d45_lines": [],
|
|
"d135_lines": [],
|
|
}
|
|
|
|
ys, xs = np.where(binary)
|
|
|
|
# === BOUNDING BOX ===
|
|
x_min, x_max = int(xs.min()), int(xs.max())
|
|
y_min, y_max = int(ys.min()), int(ys.max())
|
|
|
|
bbox_w = x_max - x_min
|
|
bbox_h = y_max - y_min
|
|
|
|
# === CENTERS ===
|
|
center_x = (x_min + x_max) // 2
|
|
center_y = (y_min + y_max) // 2
|
|
|
|
# === CENTER OF MASS ===
|
|
if HAS_SCIPY:
|
|
com_y, com_x = ndimage.center_of_mass(binary)
|
|
com_x, com_y = int(com_x), int(com_y)
|
|
else:
|
|
com_x, com_y = center_x, center_y
|
|
|
|
# === BUILD LINES ===
|
|
h_lines = [y_min, y_max]
|
|
v_lines = [x_min, x_max]
|
|
|
|
# Add geometric center
|
|
if bbox_h > 20:
|
|
h_lines.append(center_y)
|
|
if bbox_w > 20:
|
|
v_lines.append(center_x)
|
|
|
|
# Add center of mass if different from geometric center
|
|
if bbox_h > 40 and abs(com_y - center_y) > bbox_h * 0.05:
|
|
h_lines.append(com_y)
|
|
if bbox_w > 40 and abs(com_x - center_x) > bbox_w * 0.05:
|
|
v_lines.append(com_x)
|
|
|
|
# === DENSITY-BASED DIVISIONS ===
|
|
# Find where the shape has significant internal gaps
|
|
h_divisions = _find_density_divisions(binary, axis='horizontal', min_gap=max(8, bbox_h // 20))
|
|
v_divisions = _find_density_divisions(binary, axis='vertical', min_gap=max(8, bbox_w // 20))
|
|
|
|
for y in h_divisions:
|
|
if y not in h_lines and y_min < y < y_max:
|
|
h_lines.append(y)
|
|
|
|
for x in v_divisions:
|
|
if x not in v_lines and x_min < x < x_max:
|
|
v_lines.append(x)
|
|
|
|
# === DIAGONALS (only for symmetric shapes) ===
|
|
d45_lines = []
|
|
d135_lines = []
|
|
|
|
if _is_roughly_square(bbox_w, bbox_h, tolerance=0.25):
|
|
# Check for diagonal symmetry
|
|
crop = binary[y_min:y_max+1, x_min:x_max+1]
|
|
|
|
d45_score = _diagonal_symmetry_score(crop, axis='d45')
|
|
d135_score = _diagonal_symmetry_score(crop, axis='d135')
|
|
|
|
if d45_score > symmetry_threshold:
|
|
d45_lines.append(center_x - center_y)
|
|
|
|
if d135_score > symmetry_threshold:
|
|
d135_lines.append(center_x + center_y)
|
|
|
|
# === CORNER DIAGONALS (useful for any shape) ===
|
|
# Lines from bbox corners through center
|
|
if bbox_w > 50 and bbox_h > 50:
|
|
# Only add if the shape has significant diagonal extent
|
|
diag_coverage_45 = _check_diagonal_coverage(binary, x_min, y_min, x_max, y_max, direction=45)
|
|
diag_coverage_135 = _check_diagonal_coverage(binary, x_min, y_min, x_max, y_max, direction=135)
|
|
|
|
if diag_coverage_45 > 0.3:
|
|
d45_lines.append(x_min - y_min) # top-left to bottom-right
|
|
d45_lines.append(x_max - y_max) # if different
|
|
|
|
if diag_coverage_135 > 0.3:
|
|
d135_lines.append(x_min + y_max) # bottom-left to top-right
|
|
d135_lines.append(x_max + y_min) # if different
|
|
|
|
# === CLEANUP ===
|
|
h_lines = sorted(set(h_lines))
|
|
v_lines = sorted(set(v_lines))
|
|
d45_lines = sorted(set(d45_lines))
|
|
d135_lines = sorted(set(d135_lines))
|
|
|
|
# Merge lines that are too close
|
|
h_lines = _merge_close(h_lines, min_dist=max(5, bbox_h // 30))
|
|
v_lines = _merge_close(v_lines, min_dist=max(5, bbox_w // 30))
|
|
d45_lines = _merge_close(d45_lines, min_dist=10)
|
|
d135_lines = _merge_close(d135_lines, min_dist=10)
|
|
|
|
return {
|
|
"h_lines": h_lines,
|
|
"v_lines": v_lines,
|
|
"d45_lines": d45_lines,
|
|
"d135_lines": d135_lines,
|
|
}
|
|
|
|
|
|
def _find_density_divisions(binary, axis='horizontal', min_gap=8):
|
|
"""
|
|
Find lines where pixel density drops significantly.
|
|
These indicate structural divisions (baselines, gaps between elements, etc.)
|
|
"""
|
|
divisions = []
|
|
|
|
if axis == 'horizontal':
|
|
density = binary.sum(axis=1).astype(float)
|
|
else:
|
|
density = binary.sum(axis=0).astype(float)
|
|
|
|
if len(density) < 10:
|
|
return divisions
|
|
|
|
max_d = density.max()
|
|
if max_d == 0:
|
|
return divisions
|
|
|
|
density = density / max_d
|
|
|
|
# Find valleys (low density regions)
|
|
threshold = 0.15
|
|
in_gap = False
|
|
gap_start = 0
|
|
|
|
for i, d in enumerate(density):
|
|
if d < threshold and not in_gap:
|
|
in_gap = True
|
|
gap_start = i
|
|
elif d >= threshold and in_gap:
|
|
in_gap = False
|
|
gap_size = i - gap_start
|
|
if gap_size >= min_gap:
|
|
divisions.append(gap_start + gap_size // 2)
|
|
|
|
return divisions
|
|
|
|
|
|
def _diagonal_symmetry_score(binary, axis='d45'):
|
|
"""Check how symmetric a shape is along a diagonal."""
|
|
h, w = binary.shape
|
|
if h != w:
|
|
# Pad to square for comparison
|
|
size = max(h, w)
|
|
padded = np.zeros((size, size), dtype=binary.dtype)
|
|
padded[:h, :w] = binary
|
|
binary = padded
|
|
|
|
if axis == 'd45':
|
|
flipped = binary.T
|
|
else: # d135
|
|
flipped = np.flipud(np.fliplr(binary)).T
|
|
|
|
intersection = np.logical_and(binary, flipped).sum()
|
|
union = np.logical_or(binary, flipped).sum()
|
|
|
|
if union == 0:
|
|
return 0.0
|
|
|
|
return intersection / union
|
|
|
|
|
|
def _is_roughly_square(w, h, tolerance=0.25):
|
|
"""Check if aspect ratio is close to 1:1"""
|
|
if w == 0 or h == 0:
|
|
return False
|
|
ratio = min(w, h) / max(w, h)
|
|
return ratio > (1 - tolerance)
|
|
|
|
|
|
def _check_diagonal_coverage(binary, x_min, y_min, x_max, y_max, direction=45):
|
|
"""
|
|
Check what fraction of a diagonal line through the bbox is covered by the shape.
|
|
"""
|
|
samples = 20
|
|
hits = 0
|
|
|
|
for i in range(samples):
|
|
t = i / (samples - 1)
|
|
|
|
if direction == 45:
|
|
x = int(x_min + t * (x_max - x_min))
|
|
y = int(y_min + t * (y_max - y_min))
|
|
else: # 135
|
|
x = int(x_min + t * (x_max - x_min))
|
|
y = int(y_max - t * (y_max - y_min))
|
|
|
|
if 0 <= y < binary.shape[0] and 0 <= x < binary.shape[1]:
|
|
if binary[y, x]:
|
|
hits += 1
|
|
|
|
return hits / samples
|
|
|
|
|
|
def _merge_close(lines, min_dist=5):
|
|
"""Merge lines that are within min_dist of each other."""
|
|
if len(lines) <= 1:
|
|
return lines
|
|
|
|
result = [lines[0]]
|
|
for line in lines[1:]:
|
|
if line - result[-1] >= min_dist:
|
|
result.append(line)
|
|
else:
|
|
# Keep the average? Or just skip? Let's keep first.
|
|
pass
|
|
|
|
return result
|
|
|
|
|
|
# === LEGACY API (kept for compatibility) ===
|
|
|
|
def build_alpha_grid(mask_2d):
|
|
return (mask_2d > 0).astype(np.uint8) |