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:
265
split_png_by_brightness.py
Normal file
265
split_png_by_brightness.py
Normal 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()
|
||||
Reference in New Issue
Block a user