| |
| |
|
|
| import numpy as np |
| import matplotlib.pyplot as plt |
| import cv2 |
| from PIL import Image, ImageDraw, ImageFont |
| import math |
| import io |
| import zipfile |
| import os |
| from reportlab.lib.pagesizes import letter, A4 |
| from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak |
| from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle |
| from reportlab.lib.units import inch |
| from reportlab.lib import colors |
| import gradio as gr |
| import tempfile |
| import json |
|
|
| class StringArtGenerator: |
| def __init__(self, num_pins=200, canvas_size=800): |
| self.num_pins = num_pins |
| self.canvas_size = canvas_size |
| self.pins = [] |
| self.string_paths = [] |
| self.image_processed = None |
| self.original_image = None |
| |
| def process_image(self, image_path): |
| """Process the uploaded image""" |
| |
| image = Image.open(image_path) |
| self.original_image = image.copy() |
| |
| |
| image = image.convert('L') |
| image = image.resize((self.canvas_size, self.canvas_size), Image.Resampling.LANCZOS) |
| |
| |
| img_array = np.array(image) |
| |
| |
| self.image_processed = self.preprocess_image(img_array) |
| |
| return self.image_processed |
| |
| def preprocess_image(self, img_array): |
| """Preprocess image for string art conversion""" |
| |
| blurred = cv2.GaussianBlur(img_array, (3, 3), 0) |
| |
| |
| edges = cv2.Canny(blurred, 50, 150) |
| |
| |
| |
| processed = 255 - blurred |
| |
| |
| processed = cv2.equalizeHist(processed) |
| |
| |
| combined = cv2.addWeighted(processed, 0.7, edges, 0.3, 0) |
| |
| return combined |
| |
| def generate_pins(self, shape='circle'): |
| """Generate pin positions around the perimeter""" |
| pins = [] |
| center = self.canvas_size // 2 |
| |
| if shape == 'circle': |
| radius = center - 50 |
| for i in range(self.num_pins): |
| angle = 2 * math.pi * i / self.num_pins |
| x = center + radius * math.cos(angle) |
| y = center + radius * math.sin(angle) |
| pins.append((int(x), int(y))) |
| |
| elif shape == 'square': |
| margin = 50 |
| side_pins = self.num_pins // 4 |
| |
| |
| for i in range(side_pins): |
| x = margin + i * (self.canvas_size - 2 * margin) / side_pins |
| pins.append((int(x), margin)) |
| |
| |
| for i in range(side_pins): |
| y = margin + i * (self.canvas_size - 2 * margin) / side_pins |
| pins.append((self.canvas_size - margin, int(y))) |
| |
| |
| for i in range(side_pins): |
| x = self.canvas_size - margin - i * (self.canvas_size - 2 * margin) / side_pins |
| pins.append((int(x), self.canvas_size - margin)) |
| |
| |
| for i in range(side_pins): |
| y = self.canvas_size - margin - i * (self.canvas_size - 2 * margin) / side_pins |
| pins.append((margin, int(y))) |
| |
| self.pins = pins |
| return pins |
| |
| def calculate_string_score(self, pin1_idx, pin2_idx, current_canvas): |
| """Calculate score for adding a string between two pins""" |
| pin1 = self.pins[pin1_idx] |
| pin2 = self.pins[pin2_idx] |
| |
| x1, y1 = pin1 |
| x2, y2 = pin2 |
| |
| length = int(math.sqrt((x2-x1)**2 + (y2-y1)**2)) |
| if length == 0: |
| return 0 |
| |
| score = 0 |
| for i in range(length): |
| t = i / length |
| x = int(x1 + t * (x2 - x1)) |
| y = int(y1 + t * (y2 - y1)) |
| |
| if 0 <= x < self.canvas_size and 0 <= y < self.canvas_size: |
| target_darkness = self.image_processed[y, x] |
| current_coverage = current_canvas[y, x] |
| contribution = target_darkness * (1 - current_coverage / 255) |
| score += max(0, contribution) |
| |
| return score / length |
| |
| def draw_string_on_canvas(self, pin1_idx, pin2_idx, canvas, intensity=30): |
| """Draw a string on the canvas""" |
| pin1 = self.pins[pin1_idx] |
| pin2 = self.pins[pin2_idx] |
| |
| x1, y1 = pin1 |
| x2, y2 = pin2 |
| |
| |
| dx = abs(x2 - x1) |
| dy = abs(y2 - y1) |
| x, y = x1, y1 |
| |
| x_inc = 1 if x1 < x2 else -1 |
| y_inc = 1 if y1 < y2 else -1 |
| error = dx - dy |
| |
| while True: |
| if 0 <= x < self.canvas_size and 0 <= y < self.canvas_size: |
| canvas[y, x] = min(255, canvas[y, x] + intensity) |
| |
| if x == x2 and y == y2: |
| break |
| |
| e2 = 2 * error |
| if e2 > -dy: |
| error -= dy |
| x += x_inc |
| if e2 < dx: |
| error += dx |
| y += y_inc |
| |
| def greedy_string_art(self, max_strings=2000, min_darkness_threshold=10): |
| """Generate string art using greedy algorithm""" |
| string_canvas = np.zeros((self.canvas_size, self.canvas_size)) |
| string_paths = [] |
| |
| current_pin = 0 |
| |
| for string_num in range(max_strings): |
| best_pin = -1 |
| best_score = -1 |
| |
| |
| for next_pin in range(self.num_pins): |
| if next_pin == current_pin: |
| continue |
| |
| score = self.calculate_string_score(current_pin, next_pin, string_canvas) |
| |
| if score > best_score and score > min_darkness_threshold: |
| best_score = score |
| best_pin = next_pin |
| |
| if best_pin == -1: |
| break |
| |
| string_paths.append((current_pin, best_pin)) |
| self.draw_string_on_canvas(current_pin, best_pin, string_canvas) |
| current_pin = best_pin |
| |
| self.string_paths = string_paths |
| return string_paths |
| |
| def create_visualizations(self): |
| """Create all visualization images""" |
| visualizations = {} |
| |
| |
| fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 6)) |
| |
| |
| if self.original_image: |
| ax1.imshow(self.original_image, cmap='gray' if self.original_image.mode == 'L' else None) |
| ax1.set_title('Original Image') |
| ax1.axis('off') |
| |
| |
| ax2.imshow(self.image_processed, cmap='gray') |
| ax2.set_title('Processed for String Art') |
| ax2.axis('off') |
| |
| plt.tight_layout() |
| |
| |
| buf = io.BytesIO() |
| plt.savefig(buf, format='png', dpi=150, bbox_inches='tight') |
| buf.seek(0) |
| visualizations['original_vs_processed'] = buf.getvalue() |
| plt.close() |
| |
| |
| fig, ax = plt.subplots(1, 1, figsize=(10, 10)) |
| |
| |
| if len(self.pins) > 0: |
| if abs(self.pins[0][0] - self.canvas_size//2) > abs(self.pins[0][1] - self.canvas_size//2): |
| |
| rect = plt.Rectangle((50, 50), self.canvas_size-100, self.canvas_size-100, |
| fill=False, color='black', linewidth=2) |
| ax.add_patch(rect) |
| else: |
| |
| circle = plt.Circle((self.canvas_size//2, self.canvas_size//2), |
| self.canvas_size//2 - 50, fill=False, color='black', linewidth=2) |
| ax.add_patch(circle) |
| |
| |
| for i, (x, y) in enumerate(self.pins): |
| ax.plot(x, y, 'ro', markersize=4) |
| if i % (max(1, len(self.pins)//20)) == 0: |
| ax.text(x+15, y+15, str(i), fontsize=8, ha='left', va='bottom', |
| bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8)) |
| |
| ax.set_xlim(0, self.canvas_size) |
| ax.set_ylim(0, self.canvas_size) |
| ax.set_aspect('equal') |
| ax.invert_yaxis() |
| ax.set_title(f'Pin Template - {self.num_pins} pins\n(Print this template to mark pin positions)', |
| fontsize=14, fontweight='bold') |
| ax.grid(True, alpha=0.3) |
| |
| plt.tight_layout() |
| |
| |
| buf = io.BytesIO() |
| plt.savefig(buf, format='png', dpi=300, bbox_inches='tight') |
| buf.seek(0) |
| visualizations['pin_template'] = buf.getvalue() |
| plt.close() |
| |
| |
| string_canvas = np.zeros((self.canvas_size, self.canvas_size)) |
| for pin1_idx, pin2_idx in self.string_paths: |
| self.draw_string_on_canvas(pin1_idx, pin2_idx, string_canvas) |
| |
| fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 6)) |
| |
| |
| ax1.imshow(self.image_processed, cmap='gray') |
| ax1.set_title('Target Image') |
| ax1.axis('off') |
| |
| |
| ax2.imshow(255 - string_canvas, cmap='gray') |
| ax2.set_title(f'String Art Result\n({len(self.string_paths)} strings)') |
| ax2.axis('off') |
| |
| plt.tight_layout() |
| |
| |
| buf = io.BytesIO() |
| plt.savefig(buf, format='png', dpi=150, bbox_inches='tight') |
| buf.seek(0) |
| visualizations['string_art_result'] = buf.getvalue() |
| plt.close() |
| |
| return visualizations |
| |
| def estimate_string_length(self): |
| """Estimate total string length needed""" |
| total_length = 0 |
| for pin1_idx, pin2_idx in self.string_paths: |
| pin1 = self.pins[pin1_idx] |
| pin2 = self.pins[pin2_idx] |
| distance = math.sqrt((pin1[0] - pin2[0])**2 + (pin1[1] - pin2[1])**2) |
| total_length += distance / 10 |
| |
| return total_length * 1.2 |
| |
| def create_instruction_pdf(self): |
| """Create a comprehensive PDF instruction manual""" |
| buffer = io.BytesIO() |
| doc = SimpleDocTemplate(buffer, pagesize=A4) |
| styles = getSampleStyleSheet() |
| story = [] |
| |
| |
| title_style = ParagraphStyle( |
| 'CustomTitle', |
| parent=styles['Heading1'], |
| fontSize=24, |
| spaceAfter=30, |
| alignment=1 |
| ) |
| story.append(Paragraph("STRING ART CONSTRUCTION MANUAL", title_style)) |
| story.append(Spacer(1, 20)) |
| |
| |
| story.append(Paragraph("MATERIALS NEEDED", styles['Heading2'])) |
| materials = [ |
| f"• Circular or square frame ({self.canvas_size//10}cm recommended)", |
| f"• {self.num_pins} small nails or pins", |
| f"• Black thread or string (approximately {self.estimate_string_length():.1f} meters)", |
| "• Hammer", |
| "• Ruler or measuring tape", |
| "• Pencil for marking", |
| "• Printed pin template (included)" |
| ] |
| |
| for material in materials: |
| story.append(Paragraph(material, styles['Normal'])) |
| |
| story.append(Spacer(1, 20)) |
| |
| |
| story.append(Paragraph("SETUP INSTRUCTIONS", styles['Heading2'])) |
| setup_steps = [ |
| f"1. Print the pin template at actual size", |
| f"2. Attach template to your frame/board", |
| f"3. Mark all {self.num_pins} pin positions", |
| f"4. Number each pin from 0 to {self.num_pins-1}", |
| f"5. Hammer nails at each marked point", |
| f"6. Leave about 5mm of nail protruding for string wrapping" |
| ] |
| |
| for step in setup_steps: |
| story.append(Paragraph(step, styles['Normal'])) |
| |
| story.append(Spacer(1, 20)) |
| |
| |
| story.append(Paragraph("CONSTRUCTION OVERVIEW", styles['Heading2'])) |
| overview = [ |
| f"Total strings to connect: {len(self.string_paths)}", |
| f"Estimated completion time: {len(self.string_paths)//20}-{len(self.string_paths)//10} minutes", |
| f"Starting pin: {self.string_paths[0][0] if self.string_paths else 0}", |
| f"Estimated string length: {self.estimate_string_length():.1f} meters" |
| ] |
| |
| for info in overview: |
| story.append(Paragraph(info, styles['Normal'])) |
| |
| story.append(PageBreak()) |
| |
| |
| story.append(Paragraph("STRING CONNECTION SEQUENCE", styles['Heading2'])) |
| story.append(Paragraph("Follow this sequence exactly, connecting each numbered string from the first pin to the second pin:", styles['Normal'])) |
| story.append(Spacer(1, 10)) |
| |
| |
| strings_per_page = 40 |
| total_pages = (len(self.string_paths) + strings_per_page - 1) // strings_per_page |
| |
| for page in range(total_pages): |
| start_idx = page * strings_per_page |
| end_idx = min((page + 1) * strings_per_page, len(self.string_paths)) |
| |
| |
| table_data = [['String #', 'From Pin', 'To Pin', 'String #', 'From Pin', 'To Pin']] |
| |
| for i in range(start_idx, end_idx, 2): |
| row = [] |
| |
| pin1, pin2 = self.string_paths[i] |
| row.extend([str(i+1), str(pin1), str(pin2)]) |
| |
| |
| if i+1 < end_idx: |
| pin1_2, pin2_2 = self.string_paths[i+1] |
| row.extend([str(i+2), str(pin1_2), str(pin2_2)]) |
| else: |
| row.extend(['', '', '']) |
| |
| table_data.append(row) |
| |
| |
| table = Table(table_data, colWidths=[0.8*inch, 0.8*inch, 0.8*inch, 0.8*inch, 0.8*inch, 0.8*inch]) |
| table.setStyle(TableStyle([ |
| ('BACKGROUND', (0, 0), (-1, 0), colors.grey), |
| ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), |
| ('ALIGN', (0, 0), (-1, -1), 'CENTER'), |
| ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), |
| ('FONTSIZE', (0, 0), (-1, 0), 10), |
| ('BOTTOMPADDING', (0, 0), (-1, 0), 12), |
| ('BACKGROUND', (0, 1), (-1, -1), colors.beige), |
| ('FONTSIZE', (0, 1), (-1, -1), 8), |
| ('GRID', (0, 0), (-1, -1), 1, colors.black) |
| ])) |
| |
| story.append(table) |
| |
| if page < total_pages - 1: |
| story.append(PageBreak()) |
| |
| story.append(PageBreak()) |
| |
| |
| story.append(Paragraph("CONSTRUCTION TIPS", styles['Heading2'])) |
| tips = [ |
| f"• Start with string tied to pin {self.string_paths[0][0] if self.string_paths else 0}", |
| "• Maintain consistent tension throughout", |
| "• Don't pull too tight - the string should have slight slack", |
| "• If you make a mistake, carefully backtrack to the error", |
| "• Take breaks every 100-200 strings to avoid fatigue", |
| "• The image will become clearer as you add more strings", |
| "• Mark your progress every 50 strings to track completion", |
| "• Use good lighting to see pin numbers clearly" |
| ] |
| |
| for tip in tips: |
| story.append(Paragraph(tip, styles['Normal'])) |
| |
| |
| doc.build(story) |
| buffer.seek(0) |
| return buffer.getvalue() |
|
|
| def generate_string_art(image, num_pins, max_strings, shape, progress=gr.Progress()): |
| """Main function to generate string art from uploaded image""" |
| if image is None: |
| return None, None, None, "Please upload an image first." |
| |
| progress(0.1, desc="Initializing...") |
| |
| |
| generator = StringArtGenerator(num_pins=num_pins) |
| |
| progress(0.2, desc="Processing image...") |
| |
| |
| generator.process_image(image) |
| |
| progress(0.3, desc="Generating pin layout...") |
| |
| |
| generator.generate_pins(shape=shape) |
| |
| progress(0.5, desc="Calculating optimal string paths...") |
| |
| |
| string_paths = generator.greedy_string_art(max_strings=max_strings) |
| |
| progress(0.7, desc="Creating visualizations...") |
| |
| |
| visualizations = generator.create_visualizations() |
| |
| progress(0.9, desc="Generating instruction manual...") |
| |
| |
| pdf_content = generator.create_instruction_pdf() |
| |
| progress(1.0, desc="Complete!") |
| |
| |
| with tempfile.NamedTemporaryFile(mode='wb', suffix='.pdf', delete=False) as f: |
| f.write(pdf_content) |
| pdf_path = f.name |
| |
| with tempfile.NamedTemporaryFile(mode='wb', suffix='.png', delete=False) as f: |
| f.write(visualizations['pin_template']) |
| template_path = f.name |
| |
| with tempfile.NamedTemporaryFile(mode='wb', suffix='.png', delete=False) as f: |
| f.write(visualizations['string_art_result']) |
| result_path = f.name |
| |
| |
| zip_buffer = io.BytesIO() |
| with zipfile.ZipFile(zip_buffer, 'w') as zip_file: |
| zip_file.writestr('instruction_manual.pdf', pdf_content) |
| zip_file.writestr('pin_template.png', visualizations['pin_template']) |
| zip_file.writestr('string_art_result.png', visualizations['string_art_result']) |
| zip_file.writestr('original_vs_processed.png', visualizations['original_vs_processed']) |
| |
| |
| string_data = { |
| 'num_pins': num_pins, |
| 'num_strings': len(string_paths), |
| 'string_paths': string_paths, |
| 'estimated_length_meters': generator.estimate_string_length() |
| } |
| zip_file.writestr('string_data.json', json.dumps(string_data, indent=2)) |
| |
| zip_buffer.seek(0) |
| |
| with tempfile.NamedTemporaryFile(mode='wb', suffix='.zip', delete=False) as f: |
| f.write(zip_buffer.getvalue()) |
| zip_path = f.name |
| |
| |
| summary = f""" |
| ## String Art Generation Complete! 🎨 |
| |
| **Statistics:** |
| - **Pins:** {num_pins} |
| - **Strings:** {len(string_paths)} |
| - **Estimated String Length:** {generator.estimate_string_length():.1f} meters |
| - **Estimated Construction Time:** {len(string_paths)//20}-{len(string_paths)//10} minutes |
| - **Frame Shape:** {shape.title()} |
| |
| **Downloads Available:** |
| 1. **Complete Package (ZIP)** - Contains all files |
| 2. **Instruction Manual (PDF)** - Step-by-step construction guide |
| 3. **Pin Template (PNG)** - Print this to mark pin positions |
| |
| **Next Steps:** |
| 1. Download the complete package |
| 2. Print the pin template at actual size |
| 3. Follow the instruction manual |
| 4. Create your string art masterpiece! |
| """ |
| |
| return pdf_path, template_path, zip_path, summary |
|
|
| |
| def create_interface(): |
| with gr.Blocks(title="String Art Generator", theme=gr.themes.Soft()) as app: |
| gr.Markdown(""" |
| # 🎨 String Art Generator |
| |
| Transform any image into detailed string art instructions! Upload an image and get: |
| - Step-by-step construction manual |
| - Printable pin template |
| - Complete material list |
| - Downloadable instruction package |
| """) |
| |
| with gr.Row(): |
| with gr.Column(scale=1): |
| image_input = gr.Image( |
| label="Upload Image", |
| type="filepath", |
| height=300 |
| ) |
| |
| with gr.Row(): |
| num_pins = gr.Slider( |
| minimum=100, |
| maximum=400, |
| value=200, |
| step=10, |
| label="Number of Pins", |
| info="More pins = higher detail, longer construction" |
| ) |
| |
| with gr.Row(): |
| max_strings = gr.Slider( |
| minimum=500, |
| maximum=5000, |
| value=2000, |
| step=100, |
| label="Maximum Strings", |
| info="More strings = better quality, longer time" |
| ) |
| |
| shape = gr.Radio( |
| choices=["circle", "square"], |
| value="circle", |
| label="Frame Shape", |
| info="Choose the shape of your frame" |
| ) |
| |
| generate_btn = gr.Button( |
| "Generate String Art Instructions", |
| variant="primary", |
| size="lg" |
| ) |
| |
| with gr.Column(scale=2): |
| summary_output = gr.Markdown(label="Generation Summary") |
| |
| with gr.Row(): |
| pdf_download = gr.File( |
| label="📋 Instruction Manual (PDF)", |
| visible=True |
| ) |
| template_download = gr.File( |
| label="📍 Pin Template (PNG)", |
| visible=True |
| ) |
| |
| zip_download = gr.File( |
| label="📦 Complete Package (ZIP)", |
| visible=True |
| ) |
| |
| |
| generate_btn.click( |
| fn=generate_string_art, |
| inputs=[image_input, num_pins, max_strings, shape], |
| outputs=[pdf_download, template_download, zip_download, summary_output] |
| ) |
| |
| gr.Markdown(""" |
| ## How to Use: |
| 1. **Upload** your image (photos, artwork, logos work well) |
| 2. **Adjust** settings based on desired complexity |
| 3. **Generate** your string art instructions |
| 4. **Download** the complete package |
| 5. **Print** the pin template and follow the manual |
| |
| ## Tips for Best Results: |
| - Use high-contrast images |
| - Simple compositions work better than complex scenes |
| - Black and white or monochrome images are ideal |
| - Portraits and geometric designs are excellent choices |
| |
| --- |
| *Created with ❤️ for the maker community* |
| """) |
| |
| return app |
|
|
| |
| if __name__ == "__main__": |
| |
| app = create_interface() |
| app.launch() |
|
|
| |
| """ |
| gradio |
| opencv-python |
| pillow |
| numpy |
| matplotlib |
| reportlab |
| scipy |
| """ |