Add PNG to SVG color separation tool for multi-color 3D printing

Created split_png_by_brightness.py to split rasterized logos into separate
SVG files by color for Underground Magnetics logo conversion.

Key features:
- OpenCV contour hierarchy detection for proper hole handling
- Letters with enclosed shapes (d, o, g, etc.) now render correctly
- Tight brightness thresholds to avoid antialiasing artifacts
- Automatic bounding box cropping for optimal file sizes
- Black text: brightness < 40 (236k pixels, 21 shape groups)
- Grey icon: brightness 108-118 (119k pixels, 2 shape groups)

Results:
- um_black.svg: 6.1KB, 3070x233px (was 139KB, 3613x391px)
- um_grey.svg: 728B, 413x390px (was 253KB, 3613x391px)

Files:
- split_png_by_brightness.py: Main color separation tool
- IMPROVEMENTS.md: Detailed changelog and comparison
- README_underground_magnetics.md: Usage documentation
- underground-magnetics.eps: Source logo file
- um_black.svg: Separated black text (cropped)
- um_grey.svg: Separated grey icon (cropped)
- requirements.txt: Added opencv-python dependency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-31 09:51:24 -05:00
parent 8b80f68a19
commit 2719ec244d
7 changed files with 423 additions and 0 deletions

265
split_png_by_brightness.py Normal file
View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""
Split a PNG file into multiple SVG files based on brightness/color clusters.
Useful for multi-color 3D printing when the source is a rasterized logo.
"""
import argparse
import numpy as np
from PIL import Image
from pathlib import Path
import cv2
import xml.etree.ElementTree as ET
def extract_color_regions(image_path: Path, dark_threshold: int = 30, mid_min: int = 108, mid_max: int = 118):
"""
Extract different color regions from PNG based on brightness.
Uses tighter thresholds to avoid antialiasing artifacts.
Returns dict of {color_name: binary_mask}
"""
img = Image.open(image_path).convert('RGBA')
img_array = np.array(img)
# Get alpha mask (non-transparent pixels)
alpha = img_array[..., 3] > 128
# Calculate brightness for each pixel
rgb = img_array[..., :3]
brightness = np.mean(rgb, axis=-1)
# Create masks for different brightness levels
masks = {}
# Dark pixels (black text) - very tight threshold to avoid antialiasing
dark_mask = alpha & (brightness < dark_threshold)
if np.sum(dark_mask) > 100:
masks['black'] = dark_mask
# Mid-range pixels (grey icon) - tight range to get only the core grey
mid_mask = alpha & (brightness >= mid_min) & (brightness <= mid_max)
if np.sum(mid_mask) > 100:
masks['grey'] = mid_mask
return masks
def compute_contour_area(contour):
"""Compute the signed area of a contour (positive = clockwise, negative = counter-clockwise)"""
if len(contour) < 3:
return 0
# Shoelace formula
x = contour[:, 0]
y = contour[:, 1]
return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
def mask_to_svg_paths_with_holes(mask: np.ndarray, simplify_epsilon: float = 1.0):
"""
Convert a binary mask to SVG path data with proper hole handling.
Returns list of path d attributes with fill-rule evenodd.
"""
# Convert to uint8 for OpenCV
mask_uint8 = (mask * 255).astype(np.uint8)
# Find contours with hierarchy (to detect holes)
contours, hierarchy = cv2.findContours(mask_uint8, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
if hierarchy is None or len(contours) == 0:
return []
# Simplify contours
simplified_contours = []
for contour in contours:
if simplify_epsilon > 0:
simplified = cv2.approxPolyDP(contour, simplify_epsilon, True)
else:
simplified = contour
simplified_contours.append(simplified)
# Group contours by parent/child relationship
# hierarchy format: [Next, Previous, First_Child, Parent]
hierarchy = hierarchy[0]
# Find all top-level contours (no parent)
top_level_indices = [i for i in range(len(hierarchy)) if hierarchy[i][3] == -1]
paths = []
for top_idx in top_level_indices:
contour = simplified_contours[top_idx]
if len(contour) < 3:
continue
# Start with outer contour
path_data = contour_to_svg_path(contour)
# Find all children (holes) of this contour
child_idx = hierarchy[top_idx][2]
while child_idx != -1:
child_contour = simplified_contours[child_idx]
if len(child_contour) >= 3:
# Add hole to the same path (evenodd fill-rule will handle it)
path_data += " " + contour_to_svg_path(child_contour)
# Move to next sibling
child_idx = hierarchy[child_idx][0]
paths.append(path_data)
return paths
def contour_to_svg_path(contour):
"""Convert OpenCV contour to SVG path data"""
# OpenCV contours are shape (N, 1, 2)
points = contour.reshape(-1, 2)
if len(points) < 2:
return ""
path_data = f"M {points[0, 0]},{points[0, 1]}"
for point in points[1:]:
path_data += f" L {point[0]},{point[1]}"
path_data += " Z"
return path_data
def get_mask_bounds(mask: np.ndarray):
"""Get the bounding box of a binary mask"""
rows = np.any(mask, axis=1)
cols = np.any(mask, axis=0)
if not np.any(rows) or not np.any(cols):
return None
y_min, y_max = np.where(rows)[0][[0, -1]]
x_min, x_max = np.where(cols)[0][[0, -1]]
return (x_min, y_min, x_max + 1, y_max + 1)
def create_svg_from_mask(mask: np.ndarray, output_path: Path, color: str, image_size: tuple, crop_to_bounds: bool = True):
"""Create SVG file from binary mask with proper hole handling and optional cropping"""
width, height = image_size
# Get bounding box of the mask
if crop_to_bounds:
bounds = get_mask_bounds(mask)
if bounds is None:
print(f"WARNING: No content found for {color}")
return
x_min, y_min, x_max, y_max = bounds
cropped_width = x_max - x_min
cropped_height = y_max - y_min
# Crop the mask
cropped_mask = mask[y_min:y_max, x_min:x_max]
else:
x_min, y_min = 0, 0
cropped_width, cropped_height = width, height
cropped_mask = mask
# Create SVG root with cropped dimensions
svg = ET.Element('svg', {
'xmlns': 'http://www.w3.org/2000/svg',
'width': str(cropped_width),
'height': str(cropped_height),
'viewBox': f'0 0 {cropped_width} {cropped_height}'
})
# Convert mask to paths with hole detection
paths = mask_to_svg_paths_with_holes(cropped_mask, simplify_epsilon=1.0)
# Color mapping
color_hex = {
'black': '#000000',
'grey': '#808080',
'gray': '#808080',
'white': '#FFFFFF',
}.get(color, color)
# Add paths to SVG (no translation needed since we cropped the mask)
for path_data in paths:
ET.SubElement(svg, 'path', {
'd': path_data,
'fill': color_hex,
'fill-rule': 'evenodd'
})
# Write SVG
tree = ET.ElementTree(svg)
ET.indent(tree, space=' ')
tree.write(output_path, encoding='utf-8', xml_declaration=True)
if crop_to_bounds:
print(f"Created {output_path} with color {color} ({len(paths)} shape groups, cropped to {cropped_width}x{cropped_height})")
else:
print(f"Created {output_path} with color {color} ({len(paths)} shape groups)")
def main():
parser = argparse.ArgumentParser(
description='Split PNG into separate SVG files by brightness/color'
)
parser.add_argument('input', help='Input PNG file')
parser.add_argument('--output-dir', '-o', help='Output directory (default: same as input)')
parser.add_argument('--prefix', '-p', help='Prefix for output files (default: input filename)')
parser.add_argument('--dark-threshold', type=int, default=30,
help='Brightness threshold for dark/black elements (default: 30)')
parser.add_argument('--mid-min', type=int, default=108,
help='Minimum brightness for mid/grey elements (default: 108)')
parser.add_argument('--mid-max', type=int, default=118,
help='Maximum brightness for mid/grey elements (default: 118)')
parser.add_argument('--no-crop', action='store_true',
help='Do not crop to bounding box (default: crop enabled)')
args = parser.parse_args()
input_path = Path(args.input)
if not input_path.exists():
print(f"ERROR: File not found: {input_path}")
return 1
# Setup output directory
if args.output_dir:
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
else:
output_dir = input_path.parent
# Setup prefix
prefix = args.prefix if args.prefix else input_path.stem
# Load image to get size
img = Image.open(input_path)
image_size = img.size
# Extract color regions
print(f"Analyzing colors in {input_path}...")
masks = extract_color_regions(input_path, args.dark_threshold, args.mid_min, args.mid_max)
if not masks:
print("No color regions found!")
return 1
print(f"\nFound {len(masks)} color regions:")
for color, mask in masks.items():
pixel_count = np.sum(mask)
print(f" {color}: {pixel_count:,} pixels")
# Create SVG for each color
print(f"\nCreating separate SVG files...")
crop_enabled = not args.no_crop
for color, mask in masks.items():
output_path = output_dir / f"{prefix}_{color}.svg"
create_svg_from_mask(mask, output_path, color, image_size, crop_to_bounds=crop_enabled)
print(f"\nDone! Created {len(masks)} SVG files in {output_dir}")
if __name__ == '__main__':
main()