diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..034658e --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,62 @@ +# Improvements to split_png_by_brightness.py + +## Version 2 Fixes + +### 1. Proper Hole Handling +**Problem**: Letters with enclosed shapes (like "d", "o", "g") were missing their holes, making them appear filled. + +**Solution**: +- Switched from `scikit-image` contour detection to OpenCV's `cv2.findContours` with `RETR_CCOMP` mode +- This detects contour hierarchy (parent/child relationships) +- Holes are now included in the same SVG path as their parent contour +- Using `fill-rule="evenodd"` properly renders the holes + +### 2. Better Color Detection +**Problem**: Antialiasing pixels around edges were being included, creating artifacts and incorrect bounding boxes. + +**Solution**: +- Changed from single threshold to tight ranges +- Black text: brightness < 40 (was < 50) +- Grey icon: brightness 108-118 (was 80-120) +- This excludes antialiased edge pixels and prevents the grey icon from spanning the full image width + +### 3. Automatic Bounding Box Cropping +**Problem**: SVG files contained a lot of empty space. + +**Solution**: +- Added automatic cropping to bounding box by default +- Each SVG is sized to fit its content exactly +- `--no-crop` flag available if full canvas is needed + +## Results + +**Before (v1)**: +- um_black.svg: 139K, 27 separate paths, many filled letters +- um_grey.svg: 253K, 1609 paths, antialiasing artifacts, full width (3613px) + +**After (v2)**: +- um_black.svg: 6.1K, 21 shape groups with proper holes, cropped to 3070x233 +- um_grey.svg: 728B, 2 shape groups, clean edges, cropped to 413x390 + +## Dependencies Added +- opencv-python==4.13.0.92 + +## Default Parameters +- Black threshold: < 40 brightness +- Grey range: 108-118 brightness +- Cropping: enabled by default + +## Usage +```bash +# Default (optimized for Underground Magnetics logo) +python split_png_by_brightness.py input.png --prefix output + +# Custom thresholds +python split_png_by_brightness.py input.png \ + --dark-threshold 40 \ + --mid-min 108 \ + --mid-max 118 + +# Disable cropping +python split_png_by_brightness.py input.png --no-crop +``` diff --git a/README_underground_magnetics.md b/README_underground_magnetics.md new file mode 100644 index 0000000..12cf4ca --- /dev/null +++ b/README_underground_magnetics.md @@ -0,0 +1,66 @@ +# Underground Magnetics Logo - Color Separation + +Successfully split the Underground Magnetics EPS logo into separate SVG files by color for multi-color 3D printing. + +## Process + +1. **EPS to PNG Conversion**: Used ImageMagick to convert `underground-magnetics.eps` to PNG format + - Output: `underground-magnetics-0.png` and `underground-magnetics-1.png` + +2. **Color Separation**: Used `split_png_by_brightness.py` to extract separate colors + - Analyzed brightness values to distinguish grey icon from black text + - Created separate SVG files for each color region + +## Output Files + +- **underground-magnetics_grey.svg** - Grey "UM" icon (126,801 pixels, 1609 paths) +- **underground-magnetics_black.svg** - Black "Underground Magnetics" text (237,130 pixels, 27 paths) + +## Usage + +To convert these SVG files to 3D models for multi-color printing: + +```bash +# Convert grey icon to STL +python svg_logo_to_stl.py underground-magnetics_grey.svg um_grey --width-mm 100 --base-thickness 1.5 --feature-height 2.5 + +# Convert black text to STL +python svg_logo_to_stl.py underground-magnetics_black.svg um_black --width-mm 100 --base-thickness 1.5 --feature-height 2.5 +``` + +## Tools Created + +### split_png_by_brightness.py +Splits PNG images into separate SVG files based on brightness/color clusters. + +**Usage:** +```bash +python split_png_by_brightness.py [options] + +Options: + --output-dir, -o Output directory (default: same as input) + --prefix, -p Prefix for output files (default: input filename) + --dark-threshold INT Brightness threshold for dark/black (default: 50) + --mid-threshold INT Brightness threshold for mid/grey (default: 150) +``` + +**Example:** +```bash +python split_png_by_brightness.py underground-magnetics-0.png --prefix underground-magnetics +``` + +This creates separate SVG files for each color region detected in the PNG. + +## Color Detection + +The script analyzes pixel brightness to separate colors: +- **Dark pixels** (brightness < 50): Black text → `_black.svg` +- **Mid-range pixels** (50 ≤ brightness < 150): Grey icon → `_grey.svg` +- **Light pixels** (brightness ≥ 150): White background (ignored) + +## Next Steps + +1. Import the separate SVG files into the 3D conversion tool +2. Generate STL files for each color +3. Create 3MF assembly with multiple selectable parts +4. Import into BambuStudio for multi-color printing diff --git a/requirements.txt b/requirements.txt index f05fe01..41938fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ lazy-loader==0.5 mapbox_earcut==2.0.0 networkx==3.4.2 numpy==2.2.6 +opencv-python==4.13.0.92 packaging==26.2 pillow==12.2.0 pycparser==3.0 diff --git a/split_png_by_brightness.py b/split_png_by_brightness.py new file mode 100644 index 0000000..568ae26 --- /dev/null +++ b/split_png_by_brightness.py @@ -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() diff --git a/um_black.svg b/um_black.svg new file mode 100644 index 0000000..047b286 --- /dev/null +++ b/um_black.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/um_grey.svg b/um_grey.svg new file mode 100644 index 0000000..ae86f6e --- /dev/null +++ b/um_grey.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/underground-magnetics.eps b/underground-magnetics.eps new file mode 100644 index 0000000..a599f76 Binary files /dev/null and b/underground-magnetics.eps differ