diff --git a/.gitignore b/.gitignore index b7faf40..0ffdd31 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,10 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Thread Art Generator specific +test_output/ +demo_output/ +test_images/ +test_files/ +screenshots/ diff --git a/README.md b/README.md index a6904d8..6108704 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,115 @@ -# thread-art -Software to generate paths for thread art. +# Thread Art Generator + +A PySide6 GUI application for generating thread art paths from anchor points and target images. + +## Features + +- **DXF Import**: Load anchor point positions from DXF files +- **Image Processing**: Process target images for optimal thread art generation +- **Thread Path Generation**: Generate optimal threading paths using advanced algorithms +- **CSV Export**: Export thread paths as CSV files for manufacturing +- **Simulation**: Generate realistic previews of the final thread art +- **User-friendly GUI**: Intuitive interface built with PySide6 + +## Installation + +1. Clone the repository: +```bash +git clone https://github.com/juice-tea/thread-art.git +cd thread-art +``` + +2. Install required dependencies: +```bash +pip install -r requirements.txt +``` + +3. Run the application: +```bash +python main.py +``` + +## Usage + +### 1. Load Anchor Points +- Click "Load DXF (Anchor Points)" to load anchor point positions from a DXF file +- Or use the default circular anchor pattern (256 points) +- Supported DXF entities: POINT, CIRCLE, LINE, POLYLINE + +### 2. Load Target Image +- Click "Load Image" to select your target image +- Supported formats: PNG, JPG, JPEG, BMP, TIFF +- Image will be automatically preprocessed for optimal thread art generation + +### 3. Configure Parameters +- **Max Lines**: Maximum number of thread lines (100-10000) +- **Line Weight**: Darkness factor for each thread line (1.0-100.0) + +### 4. Generate Thread Art +- Click "Generate Thread Art" to start the generation process +- The algorithm will find the optimal path between anchor points +- Progress will be shown in the progress bar + +### 5. Export Results +- **Export Path to CSV**: Save the threading sequence as a CSV file +- **Export Simulation Image**: Save the thread art preview as an image + +## File Formats + +### DXF Files +The application reads anchor points from DXF files. Supported entities: +- `POINT`: Direct anchor points +- `CIRCLE`: Center points used as anchors +- `LINE`: Both endpoints used as anchors +- `POLYLINE`/`LWPOLYLINE`: All vertices used as anchors + +### CSV Output +The exported CSV contains: +- Step number +- Anchor point index +- X and Y coordinates (normalized 0-1) +- Metadata about generation parameters + +### Images +- Input: PNG, JPG, JPEG, BMP, TIFF +- Output: PNG simulation images + +## Algorithm + +The thread art generation uses a greedy algorithm that: +1. Starts from an initial anchor point +2. Evaluates all possible next connections +3. Selects the line that best matches the target image darkness +4. Updates the working image to simulate thread placement +5. Repeats until the maximum line count is reached + +## Testing + +Run the test suite to verify installation: +```bash +python test_complete.py +``` + +Create test files for experimentation: +```bash +python create_test_files.py +``` + +## System Requirements + +- Python 3.8+ +- PySide6 (Qt6) +- NumPy +- OpenCV +- ezdxf +- Pillow +- SciPy +- Matplotlib + +## License + +MIT License - see LICENSE file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit pull requests or open issues for bugs and feature requests. diff --git a/create_demo.py b/create_demo.py new file mode 100644 index 0000000..121d130 --- /dev/null +++ b/create_demo.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Demo of thread art generation functionality. +""" + +import os +import matplotlib.pyplot as plt +import numpy as np +from src.core.dxf_processor import DXFProcessor +from src.core.image_processor import ImageProcessor +from src.core.thread_art import ThreadArtGenerator + +def create_demo(): + """Create a visual demo of the thread art generation process.""" + print("๐ŸŽจ Creating Thread Art Generation Demo") + print("=" * 40) + + # Load test data + print("Loading test data...") + anchors = DXFProcessor.create_circular_anchors(128) # More points for better result + if os.path.exists('test_images/test_image.png'): + image = ImageProcessor.load_and_preprocess('test_images/test_image.png', (400, 400)) + else: + # Create a simple test pattern + image = np.ones((400, 400), dtype=np.uint8) * 255 + image[150:250, 150:250] = 100 # Dark square + + print(f"Using {len(anchors)} anchor points") + print(f"Image size: {image.shape}") + + # Generate thread art + print("Generating thread art...") + generator = ThreadArtGenerator(anchors, image) + path = generator.generate_path(max_lines=500, line_weight=30) + simulation = generator.generate_simulation(path) + + print(f"Generated path with {len(path)} lines") + + # Create visualization + fig, axes = plt.subplots(2, 2, figsize=(12, 10)) + fig.suptitle('Thread Art Generator - Demo Results', fontsize=16, fontweight='bold') + + # Plot 1: Anchor points + anchor_array = np.array(anchors) + axes[0, 0].scatter(anchor_array[:, 0], anchor_array[:, 1], s=10, c='red', alpha=0.7) + axes[0, 0].set_title(f'Anchor Points ({len(anchors)} points)') + axes[0, 0].set_aspect('equal') + axes[0, 0].grid(True, alpha=0.3) + + # Plot 2: Original image + axes[0, 1].imshow(image, cmap='gray') + axes[0, 1].set_title('Target Image (Processed)') + axes[0, 1].axis('off') + + # Plot 3: Path visualization (first 50 lines) + axes[1, 0].scatter(anchor_array[:, 0], anchor_array[:, 1], s=5, c='lightgray', alpha=0.5) + for i in range(min(50, len(path) - 1)): + p1 = anchors[path[i]] + p2 = anchors[path[i + 1]] + axes[1, 0].plot([p1[0], p2[0]], [p1[1], p2[1]], 'b-', alpha=0.6, linewidth=0.5) + axes[1, 0].set_title(f'Thread Path (first 50 of {len(path)} lines)') + axes[1, 0].set_aspect('equal') + + # Plot 4: Final simulation + axes[1, 1].imshow(simulation, cmap='gray') + axes[1, 1].set_title('Thread Art Simulation') + axes[1, 1].axis('off') + + plt.tight_layout() + + # Save the demo + os.makedirs('demo_output', exist_ok=True) + plt.savefig('demo_output/thread_art_demo.png', dpi=150, bbox_inches='tight') + print("Demo visualization saved: demo_output/thread_art_demo.png") + + # Save individual images + plt.figure(figsize=(8, 8)) + plt.imshow(simulation, cmap='gray') + plt.title('Thread Art Simulation Result', fontsize=14, fontweight='bold') + plt.axis('off') + plt.savefig('demo_output/simulation_result.png', dpi=150, bbox_inches='tight') + print("Simulation result saved: demo_output/simulation_result.png") + + return True + +if __name__ == "__main__": + create_demo() + print("\n๐ŸŽ‰ Demo created successfully!") + print("Check the demo_output/ directory for visualization files.") \ No newline at end of file diff --git a/create_test_files.py b/create_test_files.py new file mode 100644 index 0000000..a00eb48 --- /dev/null +++ b/create_test_files.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Create test files for the thread art application. +""" + +import numpy as np +import cv2 +import ezdxf +from PIL import Image, ImageDraw +import os + +def create_test_image(): + """Create a test image for thread art generation.""" + # Create a simple test image with geometric patterns + width, height = 800, 600 + + # Create image using PIL + img = Image.new('RGB', (width, height), 'white') + draw = ImageDraw.Draw(img) + + # Draw a circle + center_x, center_y = width // 2, height // 2 + radius = min(width, height) // 4 + draw.ellipse([center_x - radius, center_y - radius, + center_x + radius, center_y + radius], + outline='black', width=3) + + # Draw some lines to create interesting patterns + for i in range(8): + angle = i * np.pi / 4 + x1 = center_x + int(radius * 0.7 * np.cos(angle)) + y1 = center_y + int(radius * 0.7 * np.sin(angle)) + x2 = center_x + int(radius * 1.3 * np.cos(angle)) + y2 = center_y + int(radius * 1.3 * np.sin(angle)) + draw.line([x1, y1, x2, y2], fill='black', width=2) + + # Add some text + try: + # Try to draw some text + draw.text((center_x - 50, center_y + radius + 20), + "Thread Art", fill='black') + except: + # If font is not available, skip text + pass + + # Save the image + img.save('test_images/test_image.png') + print("โœ“ Created test image: test_images/test_image.png") + +def create_test_dxf(): + """Create a test DXF file with anchor points.""" + # Create new DXF document + doc = ezdxf.new('R2010') + msp = doc.modelspace() + + # Create anchor points in a circle + num_points = 64 + radius = 50 + center_x, center_y = 0, 0 + + for i in range(num_points): + angle = 2 * np.pi * i / num_points + x = center_x + radius * np.cos(angle) + y = center_y + radius * np.sin(angle) + + # Add a point + msp.add_point((x, y)) + + # Add some additional points for variety + # Inner circle + inner_radius = 25 + for i in range(0, num_points, 4): # Every 4th point + angle = 2 * np.pi * i / num_points + x = center_x + inner_radius * np.cos(angle) + y = center_y + inner_radius * np.sin(angle) + msp.add_circle((x, y), 0.5) # Small circles as anchor points + + # Save the DXF file + doc.saveas('test_files/test_anchors.dxf') + print("โœ“ Created test DXF: test_files/test_anchors.dxf") + +def create_complex_test_image(): + """Create a more complex test image.""" + width, height = 800, 600 + + # Create image with OpenCV for more complex patterns + img = np.ones((height, width), dtype=np.uint8) * 255 + + # Draw a portrait-like pattern + center_x, center_y = width // 2, height // 2 + + # Face outline + cv2.ellipse(img, (center_x, center_y), (120, 160), 0, 0, 360, 100, -1) + cv2.ellipse(img, (center_x, center_y), (110, 150), 0, 0, 360, 255, -1) + + # Eyes + cv2.circle(img, (center_x - 40, center_y - 30), 15, 50, -1) + cv2.circle(img, (center_x + 40, center_y - 30), 15, 50, -1) + + # Nose + cv2.line(img, (center_x, center_y - 10), (center_x - 5, center_y + 20), 100, 2) + + # Mouth + cv2.ellipse(img, (center_x, center_y + 40), (30, 15), 0, 0, 180, 80, 2) + + # Save the image + cv2.imwrite('test_images/portrait_test.png', img) + print("โœ“ Created complex test image: test_images/portrait_test.png") + +if __name__ == "__main__": + # Create directories + os.makedirs('test_images', exist_ok=True) + os.makedirs('test_files', exist_ok=True) + + # Create test files + create_test_image() + create_test_dxf() + create_complex_test_image() + + print("\n๐ŸŽ‰ All test files created successfully!") + print("You can now use these files to test the Thread Art Generator:") + print("- DXF file: test_files/test_anchors.dxf") + print("- Test images: test_images/test_image.png, test_images/portrait_test.png") \ No newline at end of file diff --git a/demo_workflow.py b/demo_workflow.py new file mode 100644 index 0000000..abc4025 --- /dev/null +++ b/demo_workflow.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Complete demonstration of the Thread Art Generator GUI application workflow. +This script simulates the complete user workflow programmatically. +""" + +import os +import sys + +# Set Qt platform for headless operation +os.environ['QT_QPA_PLATFORM'] = 'offscreen' + +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QTimer + +def simulate_workflow(): + """Simulate the complete workflow of the Thread Art Generator.""" + print("๐Ÿงต Thread Art Generator - Complete Workflow Simulation") + print("=" * 60) + + # Import and create application + from src.gui.main_window import ThreadArtMainWindow + from src.core.image_processor import ImageProcessor + + app = QApplication([]) + window = ThreadArtMainWindow() + + print("โœ“ GUI Application initialized") + print(f"โœ“ Window title: {window.windowTitle()}") + print(f"โœ“ Default anchor points loaded: {len(window.anchor_points)}") + + # Simulate loading a DXF file + if os.path.exists('test_files/test_anchors.dxf'): + try: + from src.core.dxf_processor import DXFProcessor + dxf_anchors = DXFProcessor.load_anchor_points('test_files/test_anchors.dxf') + window.anchor_points = dxf_anchors + window.dxf_label.setText(f"test_anchors.dxf ({len(dxf_anchors)} points)") + print(f"โœ“ Loaded DXF file with {len(dxf_anchors)} anchor points") + except Exception as e: + print(f"โš  DXF loading failed: {e}") + + # Simulate loading an image + if os.path.exists('test_images/test_image.png'): + try: + processed_image = ImageProcessor.load_and_preprocess('test_images/test_image.png') + window.processed_image = processed_image + window.input_image = 'test_images/test_image.png' + window.image_label.setText("test_image.png") + window.original_display.set_image(processed_image) + print(f"โœ“ Loaded and processed image: {processed_image.shape}") + except Exception as e: + print(f"โš  Image loading failed: {e}") + + # Check if ready to generate + window._check_ready_to_generate() + print(f"โœ“ Ready to generate: {window.generate_button.isEnabled()}") + + # Simulate parameter configuration + window.max_lines_spin.setValue(200) + window.line_weight_spin.setValue(30.0) + print(f"โœ“ Parameters set: Max Lines={window.max_lines_spin.value()}, Line Weight={window.line_weight_spin.value()}") + + # Simulate generation (using the core algorithm directly) + if window.anchor_points and window.processed_image is not None: + try: + from src.core.thread_art import ThreadArtGenerator + generator = ThreadArtGenerator(window.anchor_points, window.processed_image) + path = generator.generate_path( + window.max_lines_spin.value(), + window.line_weight_spin.value() + ) + simulation = generator.generate_simulation(path) + + window.generated_path = path + window.simulation_image = simulation + window.simulation_display.set_image(simulation) + window.export_csv_button.setEnabled(True) + window.export_image_button.setEnabled(True) + + print(f"โœ“ Thread art generated: {len(path)} lines") + print(f"โœ“ Simulation created: {simulation.shape}") + except Exception as e: + print(f"โŒ Generation failed: {e}") + return False + + # Simulate CSV export + try: + from src.utils.csv_exporter import CSVExporter + os.makedirs('demo_output', exist_ok=True) + + metadata = { + "Generated by": "GUI Workflow Simulation", + "Max Lines": window.max_lines_spin.value(), + "Line Weight": window.line_weight_spin.value(), + "Anchor Points": len(window.anchor_points), + "Path Length": len(window.generated_path) + } + + CSVExporter.export_path( + window.generated_path, + window.anchor_points, + 'demo_output/workflow_path.csv', + metadata + ) + print("โœ“ CSV exported: demo_output/workflow_path.csv") + except Exception as e: + print(f"โš  CSV export failed: {e}") + + # Simulate image export + try: + ImageProcessor.save_image(window.simulation_image, 'demo_output/workflow_simulation.png') + print("โœ“ Simulation image exported: demo_output/workflow_simulation.png") + except Exception as e: + print(f"โš  Image export failed: {e}") + + print("\n๐Ÿ“Š Workflow Summary") + print("=" * 25) + print(f"Anchor Points: {len(window.anchor_points)}") + print(f"Image Size: {window.processed_image.shape if window.processed_image is not None else 'None'}") + print(f"Generated Lines: {len(window.generated_path) if window.generated_path else 0}") + print(f"Max Lines Setting: {window.max_lines_spin.value()}") + print(f"Line Weight Setting: {window.line_weight_spin.value()}") + + print("\n๐ŸŽ‰ Complete workflow simulation successful!") + print("\nFiles generated:") + print("- demo_output/workflow_path.csv") + print("- demo_output/workflow_simulation.png") + + # Show GUI capabilities summary + print("\n๐Ÿ–ฅ๏ธ GUI Features Demonstrated:") + print("- โœ“ DXF file loading for anchor points") + print("- โœ“ Image file loading and preprocessing") + print("- โœ“ Parameter configuration (max lines, line weight)") + print("- โœ“ Thread art path generation") + print("- โœ“ Real-time simulation preview") + print("- โœ“ CSV export of thread paths") + print("- โœ“ Image export of simulations") + print("- โœ“ Progress tracking and status updates") + print("- โœ“ Error handling and user feedback") + + return True + +if __name__ == "__main__": + success = simulate_workflow() + if success: + print("\nโœ… All systems operational! Thread Art Generator is ready for use.") + else: + print("\nโŒ Some issues encountered during workflow simulation.") + sys.exit(1) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..4190177 --- /dev/null +++ b/main.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" +Thread Art Generator - Main Application Entry Point + +This application generates thread art paths from anchor points (DXF) and a target image, +outputting CSV path data and a simulation of the final thread art. +""" + +import sys +from PySide6.QtWidgets import QApplication +from src.gui.main_window import ThreadArtMainWindow + + +def main(): + """Main application entry point.""" + app = QApplication(sys.argv) + app.setApplicationName("Thread Art Generator") + app.setApplicationVersion("1.0.0") + + window = ThreadArtMainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..476e13c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +PySide6>=6.6.0 +numpy>=1.24.0 +opencv-python>=4.8.0 +ezdxf>=1.1.0 +Pillow>=10.0.0 +scipy>=1.11.0 +matplotlib>=3.7.0 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..903cac6 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# Empty __init__.py files to make directories into Python packages \ No newline at end of file diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..903cac6 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1 @@ +# Empty __init__.py files to make directories into Python packages \ No newline at end of file diff --git a/src/core/dxf_processor.py b/src/core/dxf_processor.py new file mode 100644 index 0000000..7dfb9e5 --- /dev/null +++ b/src/core/dxf_processor.py @@ -0,0 +1,85 @@ +""" +DXF file processing for anchor points. +""" + +import ezdxf +from typing import List, Tuple, Optional +import numpy as np + + +class DXFProcessor: + """Processes DXF files to extract anchor points.""" + + @staticmethod + def load_anchor_points(file_path: str) -> List[Tuple[float, float]]: + """ + Load anchor points from a DXF file. + + Args: + file_path: Path to the DXF file + + Returns: + List of (x, y) coordinates normalized to [0, 1] range + """ + try: + doc = ezdxf.readfile(file_path) + modelspace = doc.modelspace() + + points = [] + + # Extract points from various DXF entities + for entity in modelspace: + if entity.dxftype() == 'POINT': + point = entity.dxf.location + points.append((point.x, point.y)) + elif entity.dxftype() == 'CIRCLE': + center = entity.dxf.center + points.append((center.x, center.y)) + elif entity.dxftype() == 'LINE': + # Add both endpoints of lines as potential anchor points + start = entity.dxf.start + end = entity.dxf.end + points.append((start.x, start.y)) + points.append((end.x, end.y)) + elif entity.dxftype() == 'LWPOLYLINE' or entity.dxftype() == 'POLYLINE': + # Add vertices of polylines + for point in entity.get_points(): + points.append((point[0], point[1])) + + if not points: + raise ValueError("No anchor points found in DXF file") + + # Normalize points to [0, 1] range + points_array = np.array(points) + min_vals = points_array.min(axis=0) + max_vals = points_array.max(axis=0) + + # Handle case where all points are the same + ranges = max_vals - min_vals + ranges[ranges == 0] = 1 + + normalized_points = (points_array - min_vals) / ranges + + return normalized_points.tolist() + + except Exception as e: + raise RuntimeError(f"Error processing DXF file: {str(e)}") + + @staticmethod + def create_circular_anchors(num_points: int = 256) -> List[Tuple[float, float]]: + """ + Create anchor points arranged in a circle (default/fallback option). + + Args: + num_points: Number of anchor points to create + + Returns: + List of (x, y) coordinates normalized to [0, 1] range + """ + angles = np.linspace(0, 2 * np.pi, num_points, endpoint=False) + + # Create points on a circle centered at (0.5, 0.5) with radius 0.4 + x_coords = 0.5 + 0.4 * np.cos(angles) + y_coords = 0.5 + 0.4 * np.sin(angles) + + return list(zip(x_coords, y_coords)) \ No newline at end of file diff --git a/src/core/image_processor.py b/src/core/image_processor.py new file mode 100644 index 0000000..6591040 --- /dev/null +++ b/src/core/image_processor.py @@ -0,0 +1,118 @@ +""" +Image processing utilities for thread art generation. +""" + +import cv2 +import numpy as np +from PIL import Image +from typing import Tuple, Optional + + +class ImageProcessor: + """Processes images for thread art generation.""" + + @staticmethod + def load_and_preprocess(file_path: str, target_size: Tuple[int, int] = (800, 600)) -> np.ndarray: + """ + Load and preprocess an image for thread art generation. + + Args: + file_path: Path to the image file + target_size: Target size (width, height) for the processed image + + Returns: + Preprocessed grayscale image as numpy array + """ + try: + # Load image using PIL for better format support + pil_image = Image.open(file_path) + + # Convert to RGB if necessary + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + # Convert to numpy array + image = np.array(pil_image) + + # Convert to OpenCV format (BGR) + image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + + # Resize to target size + image = cv2.resize(image, target_size) + + # Convert to grayscale + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + # Apply preprocessing for better thread art results + processed = ImageProcessor._enhance_for_thread_art(gray) + + return processed + + except Exception as e: + raise RuntimeError(f"Error processing image: {str(e)}") + + @staticmethod + def _enhance_for_thread_art(image: np.ndarray) -> np.ndarray: + """ + Apply enhancements to make the image more suitable for thread art. + + Args: + image: Grayscale image + + Returns: + Enhanced image + """ + # Apply Gaussian blur to reduce noise + blurred = cv2.GaussianBlur(image, (3, 3), 0) + + # Enhance contrast using CLAHE + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(blurred) + + # Apply edge enhancement to preserve important features + edges = cv2.Canny(enhanced, 50, 150) + + # Combine original with edge information + result = cv2.addWeighted(enhanced, 0.8, edges, 0.2, 0) + + return result + + @staticmethod + def save_image(image: np.ndarray, file_path: str): + """ + Save an image to file. + + Args: + image: Image array to save + file_path: Output file path + """ + try: + cv2.imwrite(file_path, image) + except Exception as e: + raise RuntimeError(f"Error saving image: {str(e)}") + + @staticmethod + def array_to_qimage(image: np.ndarray): + """ + Convert numpy array to QImage for display in Qt widgets. + + Args: + image: Image array (grayscale or BGR) + + Returns: + QImage object + """ + from PySide6.QtGui import QImage + + if len(image.shape) == 2: # Grayscale + height, width = image.shape + bytes_per_line = width + return QImage(image.data, width, height, bytes_per_line, QImage.Format_Grayscale8) + elif len(image.shape) == 3: # Color + height, width, channel = image.shape + bytes_per_line = 3 * width + # Convert BGR to RGB for Qt + rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + return QImage(rgb_image.data, width, height, bytes_per_line, QImage.Format_RGB888) + else: + raise ValueError("Unsupported image format") \ No newline at end of file diff --git a/src/core/thread_art.py b/src/core/thread_art.py new file mode 100644 index 0000000..0eca097 --- /dev/null +++ b/src/core/thread_art.py @@ -0,0 +1,154 @@ +""" +Core thread art generation logic. +""" + +import numpy as np +from typing import List, Tuple, Optional +import cv2 +from scipy.spatial.distance import cdist + + +class ThreadArtGenerator: + """Generates thread art paths from anchor points and target image.""" + + def __init__(self, anchor_points: List[Tuple[float, float]], image: np.ndarray): + """ + Initialize the thread art generator. + + Args: + anchor_points: List of (x, y) coordinates for anchor points + image: Target image as numpy array (grayscale) + """ + self.anchor_points = np.array(anchor_points) + self.image = image + self.path = [] + self.num_anchors = len(anchor_points) + + def generate_path(self, max_lines: int = 3000, line_weight: float = 25) -> List[int]: + """ + Generate the thread path using a greedy algorithm. + + Args: + max_lines: Maximum number of lines to draw + line_weight: Weight factor for line darkness + + Returns: + List of anchor point indices representing the thread path + """ + # Create a copy of the image to work with + current_image = self.image.copy().astype(np.float64) + path = [] + + # Start from the first anchor point + current_anchor = 0 + path.append(current_anchor) + + for _ in range(max_lines - 1): + best_score = -1 + best_anchor = -1 + + # Try connecting to each other anchor point + for next_anchor in range(self.num_anchors): + if next_anchor == current_anchor: + continue + + # Calculate the score for this line + score = self._calculate_line_score(current_anchor, next_anchor, current_image) + + if score > best_score: + best_score = score + best_anchor = next_anchor + + if best_anchor == -1: + break + + # Draw the line and update the working image + self._draw_line(current_anchor, best_anchor, current_image, line_weight) + path.append(best_anchor) + current_anchor = best_anchor + + return path + + def _calculate_line_score(self, anchor1: int, anchor2: int, image: np.ndarray) -> float: + """Calculate how well a line matches the target image darkness.""" + points = self._get_line_points(anchor1, anchor2) + if len(points) == 0: + return 0 + + # Get pixel values along the line + y_coords, x_coords = zip(*points) + pixel_values = image[y_coords, x_coords] + + # Score is the sum of darkness (lower pixel values = higher score) + score = np.sum(255 - pixel_values) + return score + + def _draw_line(self, anchor1: int, anchor2: int, image: np.ndarray, weight: float): + """Draw a line on the working image to simulate thread placement.""" + points = self._get_line_points(anchor1, anchor2) + if len(points) == 0: + return + + y_coords, x_coords = zip(*points) + image[y_coords, x_coords] = np.maximum(0, image[y_coords, x_coords] - weight) + + def _get_line_points(self, anchor1: int, anchor2: int) -> List[Tuple[int, int]]: + """Get pixel coordinates along a line between two anchor points.""" + p1 = self.anchor_points[anchor1] + p2 = self.anchor_points[anchor2] + + # Convert to image coordinates + h, w = self.image.shape + x1 = int(p1[0] * w) + y1 = int(p1[1] * h) + x2 = int(p2[0] * w) + y2 = int(p2[1] * h) + + # Bresenham's line algorithm + points = [] + dx = abs(x2 - x1) + dy = abs(y2 - y1) + sx = 1 if x1 < x2 else -1 + sy = 1 if y1 < y2 else -1 + err = dx - dy + + x, y = x1, y1 + while True: + if 0 <= x < w and 0 <= y < h: + points.append((y, x)) + + if x == x2 and y == y2: + break + + e2 = 2 * err + if e2 > -dy: + err -= dy + x += sx + if e2 < dx: + err += dx + y += sy + + return points + + def generate_simulation(self, path: List[int]) -> np.ndarray: + """Generate a simulation image of the thread art.""" + # Start with white background + simulation = np.ones_like(self.image) * 255 + + # Draw each line in the path + for i in range(len(path) - 1): + anchor1 = path[i] + anchor2 = path[i + 1] + self._draw_simulation_line(anchor1, anchor2, simulation) + + return simulation.astype(np.uint8) + + def _draw_simulation_line(self, anchor1: int, anchor2: int, image: np.ndarray): + """Draw a line on the simulation image.""" + points = self._get_line_points(anchor1, anchor2) + if len(points) == 0: + return + + y_coords, x_coords = zip(*points) + # Make lines darker (subtract from white background) + image[y_coords, x_coords] = np.maximum(0, image[y_coords, x_coords] - 30) \ No newline at end of file diff --git a/src/gui/__init__.py b/src/gui/__init__.py new file mode 100644 index 0000000..903cac6 --- /dev/null +++ b/src/gui/__init__.py @@ -0,0 +1 @@ +# Empty __init__.py files to make directories into Python packages \ No newline at end of file diff --git a/src/gui/generation_worker.py b/src/gui/generation_worker.py new file mode 100644 index 0000000..9121668 --- /dev/null +++ b/src/gui/generation_worker.py @@ -0,0 +1,67 @@ +""" +Worker thread for thread art generation. +""" + +from PySide6.QtCore import QObject, Signal +from typing import List, Tuple +import numpy as np + +from ..core.thread_art import ThreadArtGenerator + + +class ThreadArtWorker(QObject): + """Worker for generating thread art in a separate thread.""" + + # Signals + progress_updated = Signal(int, str) + finished = Signal(list, object) # path, simulation_image + error_occurred = Signal(str) + + def __init__(self, anchor_points: List[Tuple[float, float]], + image: np.ndarray, max_lines: int, line_weight: float): + super().__init__() + self.anchor_points = anchor_points + self.image = image + self.max_lines = max_lines + self.line_weight = line_weight + self._cancelled = False + + def run(self): + """Run the thread art generation.""" + try: + self.progress_updated.emit(0, "Initializing thread art generator...") + + # Create generator + generator = ThreadArtGenerator(self.anchor_points, self.image) + + self.progress_updated.emit(10, "Generating thread path...") + + # Generate path with progress tracking + path = self._generate_path_with_progress(generator) + + if self._cancelled: + return + + self.progress_updated.emit(80, "Creating simulation...") + + # Generate simulation + simulation = generator.generate_simulation(path) + + self.progress_updated.emit(100, "Generation complete!") + + # Emit results + self.finished.emit(path, simulation) + + except Exception as e: + self.error_occurred.emit(str(e)) + + def _generate_path_with_progress(self, generator: ThreadArtGenerator) -> List[int]: + """Generate path with progress updates.""" + # This is a simplified version - in a real implementation, + # you'd want to modify the ThreadArtGenerator to support progress callbacks + path = generator.generate_path(self.max_lines, self.line_weight) + return path + + def cancel(self): + """Cancel the generation.""" + self._cancelled = True \ No newline at end of file diff --git a/src/gui/image_display.py b/src/gui/image_display.py new file mode 100644 index 0000000..e1ed67b --- /dev/null +++ b/src/gui/image_display.py @@ -0,0 +1,81 @@ +""" +Image display widget for showing images in the GUI. +""" + +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap, QFont +import numpy as np + +from ..core.image_processor import ImageProcessor + + +class ImageDisplayWidget(QWidget): + """Widget for displaying images with zoom and scroll capabilities.""" + + def __init__(self, title: str = "Image"): + super().__init__() + self.title = title + self._setup_ui() + + def _setup_ui(self): + """Set up the widget UI.""" + layout = QVBoxLayout(self) + + # Title label + self.title_label = QLabel(self.title) + font = QFont() + font.setBold(True) + self.title_label.setFont(font) + self.title_label.setAlignment(Qt.AlignCenter) + layout.addWidget(self.title_label) + + # Scroll area for image + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setAlignment(Qt.AlignCenter) + + # Image label + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setStyleSheet("border: 1px solid gray;") + self.image_label.setMinimumSize(400, 300) + self.image_label.setText("No image loaded") + + self.scroll_area.setWidget(self.image_label) + layout.addWidget(self.scroll_area) + + # Info label + self.info_label = QLabel("Size: -") + self.info_label.setAlignment(Qt.AlignCenter) + layout.addWidget(self.info_label) + + def set_image(self, image: np.ndarray): + """ + Set the image to display. + + Args: + image: Image array (grayscale or color) + """ + try: + # Convert numpy array to QImage + qimage = ImageProcessor.array_to_qimage(image) + + # Convert to QPixmap and display + pixmap = QPixmap.fromImage(qimage) + self.image_label.setPixmap(pixmap) + self.image_label.resize(pixmap.size()) + + # Update info + height, width = image.shape[:2] + self.info_label.setText(f"Size: {width} x {height}") + + except Exception as e: + self.image_label.setText(f"Error displaying image: {str(e)}") + self.info_label.setText("Size: -") + + def clear_image(self): + """Clear the displayed image.""" + self.image_label.clear() + self.image_label.setText("No image loaded") + self.info_label.setText("Size: -") \ No newline at end of file diff --git a/src/gui/main_window.py b/src/gui/main_window.py new file mode 100644 index 0000000..4755a71 --- /dev/null +++ b/src/gui/main_window.py @@ -0,0 +1,415 @@ +""" +Main window for the Thread Art Generator application. +""" + +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QPushButton, QLabel, QFileDialog, QMessageBox, QProgressBar, + QSpinBox, QDoubleSpinBox, QGroupBox, QScrollArea, QSplitter, + QTextEdit, QStatusBar, QMenuBar, QMenu +) +from PySide6.QtCore import Qt, QThread, QTimer, QDateTime +from PySide6.QtGui import QPixmap, QAction, QFont +import os +from typing import Optional, List, Tuple + +from ..core.dxf_processor import DXFProcessor +from ..core.image_processor import ImageProcessor +from ..core.thread_art import ThreadArtGenerator +from ..utils.csv_exporter import CSVExporter +from ..utils.progress_tracker import ProgressTracker +from .image_display import ImageDisplayWidget +from .generation_worker import ThreadArtWorker + + +class ThreadArtMainWindow(QMainWindow): + """Main window for the Thread Art Generator application.""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Thread Art Generator") + self.setMinimumSize(1200, 800) + + # Initialize data + self.anchor_points: Optional[List[Tuple[float, float]]] = None + self.input_image: Optional[str] = None + self.processed_image = None + self.generated_path: Optional[List[int]] = None + self.simulation_image = None + + # Initialize worker thread + self.worker_thread: Optional[QThread] = None + self.worker: Optional[ThreadArtWorker] = None + + # Set up UI + self._setup_ui() + self._setup_menus() + self._setup_status_bar() + self._connect_signals() + + # Load default circular anchors + self._load_default_anchors() + + def _setup_ui(self): + """Set up the user interface.""" + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Main layout + main_layout = QHBoxLayout(central_widget) + + # Left panel for controls + left_panel = self._create_control_panel() + main_layout.addWidget(left_panel, 1) + + # Right panel for image display + right_panel = self._create_display_panel() + main_layout.addWidget(right_panel, 3) + + def _create_control_panel(self) -> QWidget: + """Create the left control panel.""" + panel = QWidget() + layout = QVBoxLayout(panel) + + # File input section + file_group = QGroupBox("Input Files") + file_layout = QVBoxLayout(file_group) + + # DXF file input + dxf_layout = QHBoxLayout() + self.dxf_label = QLabel("No DXF file selected") + self.dxf_button = QPushButton("Load DXF (Anchor Points)") + dxf_layout.addWidget(self.dxf_button) + dxf_layout.addWidget(self.dxf_label, 1) + file_layout.addLayout(dxf_layout) + + # Image file input + image_layout = QHBoxLayout() + self.image_label = QLabel("No image selected") + self.image_button = QPushButton("Load Image") + image_layout.addWidget(self.image_button) + image_layout.addWidget(self.image_label, 1) + file_layout.addLayout(image_layout) + + layout.addWidget(file_group) + + # Generation parameters + params_group = QGroupBox("Generation Parameters") + params_layout = QGridLayout(params_group) + + params_layout.addWidget(QLabel("Max Lines:"), 0, 0) + self.max_lines_spin = QSpinBox() + self.max_lines_spin.setRange(100, 10000) + self.max_lines_spin.setValue(3000) + params_layout.addWidget(self.max_lines_spin, 0, 1) + + params_layout.addWidget(QLabel("Line Weight:"), 1, 0) + self.line_weight_spin = QDoubleSpinBox() + self.line_weight_spin.setRange(1.0, 100.0) + self.line_weight_spin.setValue(25.0) + params_layout.addWidget(self.line_weight_spin, 1, 1) + + layout.addWidget(params_group) + + # Generation controls + gen_group = QGroupBox("Generation") + gen_layout = QVBoxLayout(gen_group) + + self.generate_button = QPushButton("Generate Thread Art") + self.generate_button.setEnabled(False) + gen_layout.addWidget(self.generate_button) + + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + gen_layout.addWidget(self.progress_bar) + + self.cancel_button = QPushButton("Cancel") + self.cancel_button.setVisible(False) + gen_layout.addWidget(self.cancel_button) + + layout.addWidget(gen_group) + + # Export controls + export_group = QGroupBox("Export") + export_layout = QVBoxLayout(export_group) + + self.export_csv_button = QPushButton("Export Path to CSV") + self.export_csv_button.setEnabled(False) + export_layout.addWidget(self.export_csv_button) + + self.export_image_button = QPushButton("Export Simulation Image") + self.export_image_button.setEnabled(False) + export_layout.addWidget(self.export_image_button) + + layout.addWidget(export_group) + + # Status text + status_group = QGroupBox("Status") + status_layout = QVBoxLayout(status_group) + + self.status_text = QTextEdit() + self.status_text.setMaximumHeight(100) + self.status_text.setReadOnly(True) + status_layout.addWidget(self.status_text) + + layout.addWidget(status_group) + + layout.addStretch() + + return panel + + def _create_display_panel(self) -> QWidget: + """Create the right display panel.""" + splitter = QSplitter(Qt.Horizontal) + + # Original image display + self.original_display = ImageDisplayWidget("Original Image") + splitter.addWidget(self.original_display) + + # Simulation display + self.simulation_display = ImageDisplayWidget("Thread Art Simulation") + splitter.addWidget(self.simulation_display) + + splitter.setSizes([1, 1]) + return splitter + + def _setup_menus(self): + """Set up the menu bar.""" + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu("File") + + open_dxf_action = QAction("Open DXF...", self) + open_dxf_action.triggered.connect(self._load_dxf_file) + file_menu.addAction(open_dxf_action) + + open_image_action = QAction("Open Image...", self) + open_image_action.triggered.connect(self._load_image_file) + file_menu.addAction(open_image_action) + + file_menu.addSeparator() + + exit_action = QAction("Exit", self) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # Help menu + help_menu = menubar.addMenu("Help") + + about_action = QAction("About", self) + about_action.triggered.connect(self._show_about) + help_menu.addAction(about_action) + + def _setup_status_bar(self): + """Set up the status bar.""" + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("Ready") + + def _connect_signals(self): + """Connect UI signals to slots.""" + self.dxf_button.clicked.connect(self._load_dxf_file) + self.image_button.clicked.connect(self._load_image_file) + self.generate_button.clicked.connect(self._start_generation) + self.cancel_button.clicked.connect(self._cancel_generation) + self.export_csv_button.clicked.connect(self._export_csv) + self.export_image_button.clicked.connect(self._export_image) + + def _load_default_anchors(self): + """Load default circular anchor points.""" + try: + self.anchor_points = DXFProcessor.create_circular_anchors(256) + self.dxf_label.setText("Default circular anchors (256 points)") + self._update_status("Default circular anchor points loaded") + self._check_ready_to_generate() + except Exception as e: + self._show_error(f"Error creating default anchors: {str(e)}") + + def _load_dxf_file(self): + """Load DXF file for anchor points.""" + file_path, _ = QFileDialog.getOpenFileName( + self, "Open DXF File", "", "DXF Files (*.dxf);;All Files (*)" + ) + + if file_path: + try: + self.anchor_points = DXFProcessor.load_anchor_points(file_path) + filename = os.path.basename(file_path) + self.dxf_label.setText(f"{filename} ({len(self.anchor_points)} points)") + self._update_status(f"Loaded {len(self.anchor_points)} anchor points from {filename}") + self._check_ready_to_generate() + except Exception as e: + self._show_error(f"Error loading DXF file: {str(e)}") + + def _load_image_file(self): + """Load image file for processing.""" + file_path, _ = QFileDialog.getOpenFileName( + self, "Open Image File", "", + "Image Files (*.png *.jpg *.jpeg *.bmp *.tiff);;All Files (*)" + ) + + if file_path: + try: + self.processed_image = ImageProcessor.load_and_preprocess(file_path) + self.input_image = file_path + filename = os.path.basename(file_path) + self.image_label.setText(filename) + + # Display the processed image + self.original_display.set_image(self.processed_image) + + self._update_status(f"Loaded and processed image: {filename}") + self._check_ready_to_generate() + except Exception as e: + self._show_error(f"Error loading image: {str(e)}") + + def _check_ready_to_generate(self): + """Check if ready to generate thread art.""" + ready = self.anchor_points is not None and self.processed_image is not None + self.generate_button.setEnabled(ready) + + def _start_generation(self): + """Start thread art generation in a separate thread.""" + if not self.anchor_points or self.processed_image is None: + return + + # Disable controls + self.generate_button.setEnabled(False) + self.progress_bar.setVisible(True) + self.cancel_button.setVisible(True) + + # Create worker thread + self.worker_thread = QThread() + self.worker = ThreadArtWorker( + self.anchor_points, + self.processed_image, + self.max_lines_spin.value(), + self.line_weight_spin.value() + ) + self.worker.moveToThread(self.worker_thread) + + # Connect signals + self.worker_thread.started.connect(self.worker.run) + self.worker.progress_updated.connect(self._update_progress) + self.worker.finished.connect(self._generation_finished) + self.worker.error_occurred.connect(self._generation_error) + + # Start the thread + self.worker_thread.start() + self._update_status("Generating thread art...") + + def _cancel_generation(self): + """Cancel the current generation.""" + if self.worker: + self.worker.cancel() + + def _update_progress(self, value: int, message: str): + """Update the progress bar.""" + self.progress_bar.setValue(value) + if message: + self.status_bar.showMessage(message) + + def _generation_finished(self, path: List[int], simulation: any): + """Handle generation completion.""" + self.generated_path = path + self.simulation_image = simulation + + # Display the simulation + self.simulation_display.set_image(simulation) + + # Enable export buttons + self.export_csv_button.setEnabled(True) + self.export_image_button.setEnabled(True) + + # Reset UI + self._reset_generation_ui() + self._update_status(f"Thread art generated successfully! Path length: {len(path)} lines") + + def _generation_error(self, error_message: str): + """Handle generation error.""" + self._show_error(f"Generation error: {error_message}") + self._reset_generation_ui() + + def _reset_generation_ui(self): + """Reset the generation UI to normal state.""" + self.generate_button.setEnabled(True) + self.progress_bar.setVisible(False) + self.cancel_button.setVisible(False) + + if self.worker_thread: + self.worker_thread.quit() + self.worker_thread.wait() + self.worker_thread = None + self.worker = None + + def _export_csv(self): + """Export the generated path to CSV.""" + if not self.generated_path or not self.anchor_points: + return + + file_path, _ = QFileDialog.getSaveFileName( + self, "Save CSV File", "thread_art_path.csv", "CSV Files (*.csv);;All Files (*)" + ) + + if file_path: + try: + metadata = { + "Max Lines": self.max_lines_spin.value(), + "Line Weight": self.line_weight_spin.value(), + "Anchor Points": len(self.anchor_points), + "Path Length": len(self.generated_path) + } + + CSVExporter.export_path( + self.generated_path, + self.anchor_points, + file_path, + metadata + ) + + self._update_status(f"Path exported to: {os.path.basename(file_path)}") + except Exception as e: + self._show_error(f"Error exporting CSV: {str(e)}") + + def _export_image(self): + """Export the simulation image.""" + if self.simulation_image is None: + return + + file_path, _ = QFileDialog.getSaveFileName( + self, "Save Simulation Image", "thread_art_simulation.png", + "PNG Files (*.png);;JPEG Files (*.jpg);;All Files (*)" + ) + + if file_path: + try: + ImageProcessor.save_image(self.simulation_image, file_path) + self._update_status(f"Simulation exported to: {os.path.basename(file_path)}") + except Exception as e: + self._show_error(f"Error exporting image: {str(e)}") + + def _update_status(self, message: str): + """Update the status display.""" + timestamp = QDateTime.currentDateTime().toString("hh:mm:ss") + self.status_text.append(f"[{timestamp}] {message}") + self.status_bar.showMessage(message) + + def _show_error(self, message: str): + """Show an error message.""" + QMessageBox.critical(self, "Error", message) + self._update_status(f"ERROR: {message}") + + def _show_about(self): + """Show about dialog.""" + QMessageBox.about( + self, "About Thread Art Generator", + "Thread Art Generator v1.0\n\n" + "Generate thread art paths from anchor points and target images.\n\n" + "Features:\n" + "โ€ข Load DXF files for anchor point positions\n" + "โ€ข Process target images for thread art generation\n" + "โ€ข Export paths as CSV files\n" + "โ€ข Generate simulation images\n\n" + "Copyright (c) 2025" + ) \ No newline at end of file diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..903cac6 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1 @@ +# Empty __init__.py files to make directories into Python packages \ No newline at end of file diff --git a/src/utils/csv_exporter.py b/src/utils/csv_exporter.py new file mode 100644 index 0000000..2fa2a46 --- /dev/null +++ b/src/utils/csv_exporter.py @@ -0,0 +1,76 @@ +""" +CSV export utilities for thread art paths. +""" + +import csv +from typing import List, Tuple +from datetime import datetime + + +class CSVExporter: + """Exports thread art paths to CSV format.""" + + @staticmethod + def export_path(path: List[int], anchor_points: List[Tuple[float, float]], + file_path: str, metadata: dict = None): + """ + Export thread path to CSV file. + + Args: + path: List of anchor point indices + anchor_points: List of (x, y) coordinates for anchor points + file_path: Output CSV file path + metadata: Optional metadata to include in header + """ + try: + with open(file_path, 'w', newline='', encoding='utf-8') as csvfile: + writer = csv.writer(csvfile) + + # Write header with metadata + writer.writerow(['# Thread Art Path Export']) + writer.writerow([f'# Generated: {datetime.now().isoformat()}']) + + if metadata: + for key, value in metadata.items(): + writer.writerow([f'# {key}: {value}']) + + writer.writerow(['#']) + writer.writerow(['# Path Data: Step, Anchor_Index, X_Coordinate, Y_Coordinate']) + + # Column headers + writer.writerow(['Step', 'Anchor_Index', 'X_Coordinate', 'Y_Coordinate']) + + # Write path data + for step, anchor_idx in enumerate(path): + if anchor_idx < len(anchor_points): + x, y = anchor_points[anchor_idx] + writer.writerow([step, anchor_idx, f'{x:.6f}', f'{y:.6f}']) + + except Exception as e: + raise RuntimeError(f"Error exporting CSV: {str(e)}") + + @staticmethod + def export_anchor_points(anchor_points: List[Tuple[float, float]], file_path: str): + """ + Export anchor points to CSV file. + + Args: + anchor_points: List of (x, y) coordinates + file_path: Output CSV file path + """ + try: + with open(file_path, 'w', newline='', encoding='utf-8') as csvfile: + writer = csv.writer(csvfile) + + # Write header + writer.writerow(['# Anchor Points Export']) + writer.writerow([f'# Generated: {datetime.now().isoformat()}']) + writer.writerow(['#']) + writer.writerow(['Index', 'X_Coordinate', 'Y_Coordinate']) + + # Write anchor points + for idx, (x, y) in enumerate(anchor_points): + writer.writerow([idx, f'{x:.6f}', f'{y:.6f}']) + + except Exception as e: + raise RuntimeError(f"Error exporting anchor points CSV: {str(e)}") \ No newline at end of file diff --git a/src/utils/progress_tracker.py b/src/utils/progress_tracker.py new file mode 100644 index 0000000..647454a --- /dev/null +++ b/src/utils/progress_tracker.py @@ -0,0 +1,54 @@ +""" +Progress tracking and status reporting utilities. +""" + +from PySide6.QtCore import QObject, Signal +from typing import Optional, Callable + + +class ProgressTracker(QObject): + """Tracks and reports progress for long-running operations.""" + + # Signal emitted when progress is updated (value, message) + progress_updated = Signal(int, str) + # Signal emitted when operation is finished + finished = Signal() + # Signal emitted when operation encounters an error + error_occurred = Signal(str) + + def __init__(self): + super().__init__() + self._current_progress = 0 + self._max_progress = 100 + self._is_cancelled = False + + def set_max_progress(self, max_value: int): + """Set the maximum progress value.""" + self._max_progress = max_value + + def update_progress(self, value: int, message: str = ""): + """Update the current progress.""" + self._current_progress = value + percentage = int((value / self._max_progress) * 100) if self._max_progress > 0 else 0 + self.progress_updated.emit(percentage, message) + + def cancel(self): + """Cancel the current operation.""" + self._is_cancelled = True + + def is_cancelled(self) -> bool: + """Check if the operation has been cancelled.""" + return self._is_cancelled + + def reset(self): + """Reset the progress tracker.""" + self._current_progress = 0 + self._is_cancelled = False + + def report_error(self, error_message: str): + """Report an error.""" + self.error_occurred.emit(error_message) + + def report_finished(self): + """Report that the operation is finished.""" + self.finished.emit() \ No newline at end of file diff --git a/take_screenshot.py b/take_screenshot.py new file mode 100644 index 0000000..b540186 --- /dev/null +++ b/take_screenshot.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Screenshot demo of the Thread Art Generator application. +""" + +import sys +import os +import time + +# Set up display environment +os.environ['QT_QPA_PLATFORM'] = 'xcb' +os.environ['DISPLAY'] = ':1' + +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QTimer +from src.gui.main_window import ThreadArtMainWindow + +def take_screenshot(): + """Take a screenshot of the application.""" + app = QApplication(sys.argv) + + # Create and show the main window + window = ThreadArtMainWindow() + window.show() + + # Load test files to show functionality + if os.path.exists('test_images/test_image.png'): + # Simulate loading an image + from src.core.image_processor import ImageProcessor + processed_image = ImageProcessor.load_and_preprocess('test_images/test_image.png') + window.processed_image = processed_image + window.input_image = 'test_images/test_image.png' + window.image_label.setText("test_image.png") + window.original_display.set_image(processed_image) + window._check_ready_to_generate() + + # Take screenshot after a brief delay + def screenshot_timer(): + pixmap = window.grab() + pixmap.save('screenshots/main_window.png') + print("Screenshot saved: screenshots/main_window.png") + app.quit() + + # Create output directory + os.makedirs('screenshots', exist_ok=True) + + # Schedule screenshot + QTimer.singleShot(1000, screenshot_timer) + + # Run the application + sys.exit(app.exec()) + +if __name__ == "__main__": + take_screenshot() \ No newline at end of file diff --git a/test_complete.py b/test_complete.py new file mode 100644 index 0000000..d103860 --- /dev/null +++ b/test_complete.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +Comprehensive test of the thread art generation workflow. +""" + +import os +import sys +import numpy as np + +# Set Qt platform for headless testing +os.environ['QT_QPA_PLATFORM'] = 'offscreen' + +def test_workflow(): + """Test the complete thread art generation workflow.""" + print("๐Ÿงต Testing Thread Art Generation Workflow") + print("=" * 50) + + # Test imports + print("1. Testing imports...") + try: + from src.core.dxf_processor import DXFProcessor + from src.core.image_processor import ImageProcessor + from src.core.thread_art import ThreadArtGenerator + from src.utils.csv_exporter import CSVExporter + print(" โœ“ All modules imported successfully") + except Exception as e: + print(f" โŒ Import error: {e}") + return False + + # Test DXF processing + print("\n2. Testing DXF processing...") + try: + # Test with default anchors + anchors = DXFProcessor.create_circular_anchors(32) + print(f" โœ“ Created {len(anchors)} default anchor points") + + # Test loading DXF file if it exists + if os.path.exists('test_files/test_anchors.dxf'): + dxf_anchors = DXFProcessor.load_anchor_points('test_files/test_anchors.dxf') + print(f" โœ“ Loaded {len(dxf_anchors)} anchor points from DXF") + anchors = dxf_anchors + else: + print(" โš  DXF file not found, using default anchors") + except Exception as e: + print(f" โŒ DXF processing error: {e}") + return False + + # Test image processing + print("\n3. Testing image processing...") + try: + if os.path.exists('test_images/test_image.png'): + processed_image = ImageProcessor.load_and_preprocess('test_images/test_image.png') + print(f" โœ“ Processed image: {processed_image.shape}") + else: + # Create a simple test image + processed_image = np.ones((400, 600), dtype=np.uint8) * 255 + # Add a simple pattern + processed_image[150:250, 250:350] = 100 + print(" โœ“ Created synthetic test image") + except Exception as e: + print(f" โŒ Image processing error: {e}") + return False + + # Test thread art generation + print("\n4. Testing thread art generation...") + try: + generator = ThreadArtGenerator(anchors, processed_image) + print(" โœ“ ThreadArtGenerator created") + + # Generate with a small number of lines for testing + path = generator.generate_path(max_lines=100, line_weight=20) + print(f" โœ“ Generated path with {len(path)} lines") + + # Generate simulation + simulation = generator.generate_simulation(path) + print(f" โœ“ Generated simulation: {simulation.shape}") + except Exception as e: + print(f" โŒ Thread art generation error: {e}") + return False + + # Test CSV export + print("\n5. Testing CSV export...") + try: + os.makedirs('test_output', exist_ok=True) + + metadata = { + "Test Run": "Automated Test", + "Anchor Points": len(anchors), + "Path Length": len(path) + } + + CSVExporter.export_path( + path, anchors, 'test_output/test_path.csv', metadata + ) + print(" โœ“ Exported path to CSV") + + CSVExporter.export_anchor_points(anchors, 'test_output/test_anchors.csv') + print(" โœ“ Exported anchor points to CSV") + except Exception as e: + print(f" โŒ CSV export error: {e}") + return False + + # Test image export + print("\n6. Testing image export...") + try: + ImageProcessor.save_image(simulation, 'test_output/test_simulation.png') + print(" โœ“ Exported simulation image") + except Exception as e: + print(f" โŒ Image export error: {e}") + return False + + print("\n๐ŸŽ‰ All tests passed successfully!") + print("\nGenerated files:") + print("- test_output/test_path.csv") + print("- test_output/test_anchors.csv") + print("- test_output/test_simulation.png") + + return True + +def test_gui_components(): + """Test GUI components without actually showing the window.""" + print("\n๐Ÿ–ฅ๏ธ Testing GUI Components") + print("=" * 30) + + try: + from PySide6.QtWidgets import QApplication + from src.gui.main_window import ThreadArtMainWindow + + # Create application but don't show window + app = QApplication.instance() + if app is None: + app = QApplication([]) + + # Create main window + window = ThreadArtMainWindow() + print(" โœ“ Main window created successfully") + + # Test some basic functionality + print(f" โœ“ Window title: {window.windowTitle()}") + print(f" โœ“ Default anchors loaded: {len(window.anchor_points) if window.anchor_points else 0} points") + + return True + + except Exception as e: + print(f" โŒ GUI test error: {e}") + return False + +if __name__ == "__main__": + print("๐Ÿงช Thread Art Generator - Comprehensive Testing") + print("=" * 60) + + # Run workflow tests + workflow_success = test_workflow() + + # Run GUI tests + gui_success = test_gui_components() + + # Summary + print("\n๐Ÿ“Š Test Summary") + print("=" * 20) + print(f"Workflow Test: {'โœ“ PASS' if workflow_success else 'โŒ FAIL'}") + print(f"GUI Test: {'โœ“ PASS' if gui_success else 'โŒ FAIL'}") + + if workflow_success and gui_success: + print("\n๐ŸŽ‰ All tests passed! The Thread Art Generator is ready to use.") + sys.exit(0) + else: + print("\nโŒ Some tests failed. Please check the errors above.") + sys.exit(1) \ No newline at end of file diff --git a/test_imports.py b/test_imports.py new file mode 100644 index 0000000..93535be --- /dev/null +++ b/test_imports.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +""" +Simple test to check if PySide6 can be imported and a basic window can be created. +""" + +import sys +import os + +# Set Qt platform plugin +os.environ['QT_QPA_PLATFORM'] = 'offscreen' + +try: + from PySide6.QtWidgets import QApplication, QWidget, QLabel, QVBoxLayout + from PySide6.QtCore import Qt + + print("โœ“ PySide6 imports successful") + + app = QApplication(sys.argv) + print("โœ“ QApplication created successfully") + + # Test basic widget creation + widget = QWidget() + layout = QVBoxLayout() + label = QLabel("Test Label") + layout.addWidget(label) + widget.setLayout(layout) + print("โœ“ Basic widget creation successful") + + # Test our custom modules + from src.core.dxf_processor import DXFProcessor + from src.core.image_processor import ImageProcessor + from src.core.thread_art import ThreadArtGenerator + from src.utils.csv_exporter import CSVExporter + + print("โœ“ All custom modules imported successfully") + + # Test anchor point creation + anchors = DXFProcessor.create_circular_anchors(10) + print(f"โœ“ Created {len(anchors)} default anchor points") + + print("\n๐ŸŽ‰ All tests passed! The application should work correctly.") + +except Exception as e: + print(f"โŒ Error: {e}") + sys.exit(1) \ No newline at end of file