Tuan Nguyen před 11 měsíci
rodič
revize
73f4ac94d8

+ 1 - 0
docker-compose.yml

@@ -8,6 +8,7 @@ services:
     volumes:
       - ./patterns:/app/patterns # Map host folder to container folder
       - ./playlists.json:/app/playlists.json
+      - ./state.json:/app/state.json
     devices:
       - "/dev/ttyACM0:/dev/ttyACM0"
     privileged: true

+ 308 - 0
static/css/image2sand.css

@@ -0,0 +1,308 @@
+/* Image Converter Dialog Styles */
+.image-converter-overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: rgba(0, 0, 0, 0.7);
+    z-index: 1000;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    opacity: 0;
+    visibility: hidden;
+    transition: opacity 0.3s, visibility 0.3s;
+}
+
+.image-converter-overlay.visible {
+    opacity: 1;
+    visibility: visible;
+}
+
+.image-converter-dialog {
+    background-color: var(--background-secondary);
+    border-radius: 8px;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+    width: 90%;
+    max-width: 1000px;
+    max-height: 90vh;
+    overflow-y: auto;
+    padding: 20px;
+    position: relative;
+    color: var(--text-primary);
+}
+
+.image-converter-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 20px;
+    border-bottom: 1px solid var(--border-primary);
+    padding-bottom: 10px;
+}
+
+.image-converter-header h2 {
+    margin: 0;
+    color: var(--theme-secondary-hover);
+}
+
+.image-converter-close {
+    background: none;
+    border: none;
+    font-size: 1.5rem;
+    cursor: pointer;
+    color: var(--text-primary);
+}
+
+.image-converter-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+    gap: 20px;
+    margin-bottom: 20px;
+}
+
+.preview-controls-container {
+    display: grid;
+    grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr);
+    gap: 20px;
+    margin-bottom: 20px;
+}
+
+@media (max-width: 768px) {
+    .image-converter-grid,
+    .preview-controls-container {
+        grid-template-columns: 1fr;
+    }
+}
+
+.image-converter-settings {
+    display: flex;
+    flex-direction: column;
+    gap: 15px;
+    padding: 15px;
+    background-color: var(--background-tertiary);
+    border-radius: 6px;
+    border: 1px solid var(--border-primary);
+}
+
+.image-converter-preview, 
+.sand-garden-preview {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    margin-bottom: 15px;
+}
+
+.image-converter-preview canvas,
+.sand-garden-preview canvas {
+    max-width: 100%;
+    border: 1px solid var(--border-primary);
+    background-color: #FFFFFF;
+    margin-bottom: 10px;
+    width: 100%;
+    height: auto;
+    aspect-ratio: 1 / 1;
+    object-fit: contain;
+    min-height: 250px;
+}
+
+/* Ensure all canvases have the same size */
+#original-image, #edge-image, #dot-image, #connect-image {
+    width: 100%;
+    height: auto;
+    aspect-ratio: 1 / 1;
+    min-height: 250px;
+}
+
+.sand-garden-preview canvas {
+    min-height: 250px;
+}
+
+.image-converter-preview h3,
+.sand-garden-preview h3 {
+    margin-top: 0;
+    margin-bottom: 8px;
+    font-size: 1rem;
+    color: var(--theme-secondary-hover);
+}
+
+.image-converter-actions {
+    display: flex;
+    justify-content: flex-end;
+    gap: 15px;
+    margin-top: 20px;
+    padding-top: 15px;
+    border-top: 1px solid var(--border-primary);
+}
+
+.image-converter-actions button {
+    padding: 10px 20px;
+    border-radius: 4px;
+    cursor: pointer;
+    font-weight: 500;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.image-converter-actions button i {
+    font-size: 1.1rem;
+}
+
+.image-converter-actions .cancel {
+    background-color: var(--color-error);
+    color: var(--text-secondary);
+    border: none;
+}
+
+.image-converter-actions .cta {
+    background-color: var(--theme-primary);
+    color: var(--text-secondary);
+    border: none;
+}
+
+.slider-container {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+}
+
+.slider-labels {
+    display: flex;
+    justify-content: space-between;
+    font-size: 0.8rem;
+    color: var(--text-primary);
+}
+
+.setting-item {
+    display: flex;
+    flex-direction: column;
+}
+
+.setting-item label {
+    margin-bottom: 5px;
+    font-weight: 500;
+    color: var(--text-primary);
+}
+
+.setting-item select,
+.setting-item input {
+    padding: 8px;
+    border-radius: 4px;
+    border: 1px solid var(--border-primary);
+    background-color: var(--input-background);
+    color: var(--input-text);
+}
+
+.checkbox-group {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 20px;
+    margin: 10px 0;
+}
+
+.checkbox-container {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    white-space: nowrap;
+}
+
+.checkbox-container label {
+    margin-bottom: 0;
+    color: var(--text-primary);
+}
+
+.generate-button-container {
+    display: flex;
+    justify-content: center;
+    margin-top: 15px;
+}
+
+.generate-button-container button {
+    padding: 10px 20px;
+    font-size: 1rem;
+    background-color: var(--theme-primary);
+    color: var(--text-secondary);
+    border: none;
+    border-radius: 4px;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.generate-button-container button:hover {
+    background-color: var(--theme-primary-hover);
+}
+
+.processing-indicator {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: var(--background-translucent);
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    z-index: 10;
+    color: var(--text-secondary);
+    font-size: 1.2rem;
+    visibility: hidden;
+    opacity: 0;
+    transition: opacity 0.3s, visibility 0.3s;
+}
+
+.processing-indicator.visible {
+    visibility: visible;
+    opacity: 1;
+}
+
+.spinner {
+    border: 4px solid rgba(255, 255, 255, 0.3);
+    border-radius: 50%;
+    border-top: 4px solid var(--theme-primary);
+    width: 40px;
+    height: 40px;
+    animation: spin 1s linear infinite;
+    margin-bottom: 15px;
+}
+
+@keyframes spin {
+    0% { transform: rotate(0deg); }
+    100% { transform: rotate(360deg); }
+}
+
+#total-points {
+    margin-top: 10px;
+    font-size: 0.9rem;
+    color: var(--text-primary);
+}
+
+/* Ensure inputs and checkboxes match the theme */
+input[type="checkbox"] {
+    accent-color: var(--theme-primary);
+}
+
+input[type="range"] {
+    accent-color: var(--theme-primary);
+    background-color: var(--input-background);
+}
+
+/* Ensure the dialog transitions smoothly with theme changes */
+.image-converter-dialog * {
+    transition: background-color var(--transition-fast),
+                color var(--transition-fast),
+                border-color var(--transition-fast);
+} 
+
+.gen-button {
+    margin-left: 10px;
+}
+
+canvas#connect-image {
+    clip-path: ellipse();
+}

+ 282 - 0
static/js/image2sand-init.js

@@ -0,0 +1,282 @@
+/**
+ * Image2Sand (https://github.com/orionwc/Image2Sand) Initialization
+ * 
+ * This script handles the initialization of the Image2Sand converter.
+ * 
+ */
+
+// Global variables for the image converter
+let originalImage = null;
+let fileName = '';
+let convertedCoordinates = null;
+let currentImageData = null;
+
+/**
+ * Open the image converter dialog with the selected image
+ * @param {File} file - The image file to convert
+ */
+function openImageConverter(file) {
+    if (!file) {
+        logMessage('No file selected for conversion.', LOG_TYPE.ERROR);
+        return;
+    }
+
+    // Check if the file is an image
+    if (!file.type.startsWith('image/')) {
+        // If not an image, let the original uploadThetaRho handle it
+        return;
+    }
+
+    fileName = file.name.split('.')[0]; // Remove extension
+    
+    // Create an image element to load the file
+    const img = new Image();
+    img.onload = function() {
+        // Draw the image on the canvas
+        originalImage = img;
+        drawAndPrepImage(img);
+        
+        // Show the converter dialog
+        const overlay = document.getElementById('image-converter-overlay');
+        overlay.classList.add('visible');
+        
+        // Initialize the UI elements
+        initializeUI();
+        
+        // Convert the image with default settings
+        convertImage();
+    };
+    
+    img.onerror = function() {
+        logMessage(`Failed to load image: ${file.name}`, LOG_TYPE.ERROR);
+    };
+    
+    // Load the image from the file
+    img.src = URL.createObjectURL(file);
+}
+
+/**
+ * Initialize UI elements for the image converter
+ */
+function initializeUI() {
+    // Set up event listeners for UI controls
+    const epsilonSlider = document.getElementById('epsilon-slider');
+    const epsilonValueDisplay = document.getElementById('epsilon-value-display');
+    
+    epsilonSlider.addEventListener('input', function() {
+        epsilonValueDisplay.textContent = this.value;
+    });
+    
+    // Set up event listeners for other controls
+    //document.getElementById('epsilon-slider').addEventListener('change', convertImage);
+    //document.getElementById('dot-number').addEventListener('change', convertImage);
+    //document.getElementById('contour-mode').addEventListener('change', convertImage);
+    //document.getElementById('is-loop').addEventListener('change', convertImage);
+    //document.getElementById('no-shortcuts').addEventListener('change', convertImage);
+}
+
+/**
+ * Save the converted pattern as a .thr file
+ */
+async function saveConvertedPattern() {
+    convertedCoordinates = document.getElementById('polar-coordinates-textarea').value;
+    if (!convertedCoordinates) {
+        logMessage('No converted coordinates to save.', LOG_TYPE.ERROR);
+        return;
+    }
+    
+    try {
+        // Create a safe filename (replace spaces and special characters)
+        const safeFileName = fileName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
+        const thrFileName = `${safeFileName}.thr`;
+        
+        // Create a Blob with the coordinates
+        const blob = new Blob([convertedCoordinates], { type: 'text/plain' });
+        
+        // Create a FormData object
+        const formData = new FormData();
+        formData.append('file', new File([blob], thrFileName, { type: 'text/plain' }));
+        
+        // Show processing indicator
+        const processingIndicator = document.getElementById('processing-status');
+        const processingMessage = document.getElementById('processing-message');
+        if (processingMessage) {
+            processingMessage.textContent = `Saving pattern as ${thrFileName}...`;
+        }
+        processingIndicator.classList.add('visible');
+        
+        // Upload the file
+        const response = await fetch('/upload_theta_rho', {
+            method: 'POST',
+            body: formData
+        });
+        
+        const result = await response.json();
+        if (result.success) {
+            const fileInput = document.getElementById('upload_file');
+            const finalFileName = 'custom_patterns/' + thrFileName;
+            logMessage(`Image converted and saved as ${finalFileName}`, LOG_TYPE.SUCCESS);
+            
+            // Close the converter dialog
+            closeImageConverter();
+
+            // clear the file input
+            fileInput.value = '';
+
+            // Refresh the file list
+            await loadThetaRhoFiles();
+            
+            // Select the newly created file
+            const fileList = document.getElementById('theta_rho_files');
+            const listItems = fileList.querySelectorAll('li');
+            for (const item of listItems) {
+                if (item.textContent === finalFileName) {
+                    selectFile(finalFileName, item);
+                    break;
+                }
+            }
+        } else {
+            logMessage(`Failed to save pattern: ${result.error || 'Unknown error'}`, LOG_TYPE.ERROR);
+        }
+    } catch (error) {
+        logMessage(`Error saving pattern: ${error.message}`, LOG_TYPE.ERROR);
+    } finally {
+        // Hide processing indicator
+        document.getElementById('processing-status').classList.remove('visible');
+    }
+}
+
+/**
+ * Clear a canvas
+ * @param {string} canvasId - The ID of the canvas element to clear
+ */
+function clearCanvas(canvasId) {
+    const canvas = document.getElementById(canvasId);
+    const ctx = canvas.getContext('2d');
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+}
+
+/**
+ * Close the image converter dialog
+ */
+function closeImageConverter() {
+    const overlay = document.getElementById('image-converter-overlay');
+    overlay.classList.remove('visible');
+    
+    // Clear the canvases
+    clearCanvas('original-image');
+    clearCanvas('edge-image');
+    clearCanvas('dot-image');
+    clearCanvas('connect-image');
+    
+    // Reset variables
+    originalImage = null;
+    fileName = '';
+    convertedCoordinates = null;
+    currentImageData = null;
+    
+    // Disable the save button
+    //document.getElementById('save-pattern-button').disabled = true;
+}
+
+async function generateOpenAIImage(apiKey, prompt) {
+    if (isGeneratingImage) {
+        logMessage("Image is still generating - please don't press the button.", LOG_TYPE.INFO);
+    } else {
+        isGeneratingImage = true;
+        document.getElementById('gen-image-button').disabled = true;
+        // Show processing indicator
+        const processingIndicator = document.getElementById('processing-status');
+        const processingMessage = document.getElementById('processing-message');
+        if (processingMessage) {
+            processingMessage.textContent = `Generating image...`;
+        }
+        processingIndicator.classList.add('visible');
+        try {
+
+            const fullPrompt = `Draw an image of the following: ${prompt}. But make it a simple black silhouette on a white background, with very minimal detail and no additional content in the image, so I can use it for a computer icon.`;
+
+            const response = await fetch('https://api.openai.com/v1/images/generations', {
+                method: 'POST',
+                headers: {
+                    'Authorization': `Bearer ${apiKey}`,
+                    'Content-Type': 'application/json'
+                },
+                body: JSON.stringify({
+                    model: 'dall-e-3',
+                    prompt: fullPrompt,
+                    size: '1024x1024',
+                    quality: 'standard',
+                    response_format: 'b64_json', // Specify base64 encoding
+                    n: 1
+                })
+            });
+
+            const data = await response.json();
+            //const imageUrl = data.data[0].url;
+            if ('error' in data) {
+                throw new Error(data.error.message);
+            }
+            const imageData = data.data[0].b64_json;
+
+            //console.log("Image Data: ", imageData);
+
+            const imgElement = new Image();
+            imgElement.onload = function() {
+                // Draw the image on the canvas
+                originalImage = imgElement;
+                drawAndPrepImage(imgElement);
+                
+                // Convert the image with default settings
+                convertImage();
+            };
+            imgElement.src = `data:image/png;base64,${imageData}`;
+
+            //console.log(`Image generated successfully`);
+            logMessage('Image generated successfully', LOG_TYPE.SUCCESS);
+        } catch (error) {
+            //console.error('Image generation error:', error);
+            logMessage('Image generation error: ' + error, LOG_TYPE.ERROR);
+        }
+        isGeneratingImage = false;
+        document.getElementById('gen-image-button').disabled = false;
+        document.getElementById('processing-status').classList.remove('visible');
+    }
+
+}
+
+
+// Override the uploadThetaRho function to handle image files
+const originalUploadThetaRho = window.uploadThetaRho;
+
+window.uploadThetaRho = async function() {
+    const fileInput = document.getElementById('upload_file');
+    const file = fileInput.files[0];
+    
+    if (!file) {
+        logMessage('No file selected for upload.', LOG_TYPE.ERROR);
+        return;
+    }
+    
+    // Check if the file is an image
+    if (file.type.startsWith('image/')) {
+        // Handle image files with the converter
+        openImageConverter(file);
+        return;
+    }
+    
+    // For non-image files, use the original function
+    await originalUploadThetaRho();
+};
+
+// Remove existing event listener and add a new one
+document.getElementById('gen-image-button').addEventListener('click', function() {    
+    let apiKey = document.getElementById('api-key').value;
+    const prompt = document.getElementById('prompt').value + (document.getElementById('googly-eyes').checked ? ' with disproportionately large googly eyes' : '');
+    // Show the converter dialog
+    const overlay = document.getElementById('image-converter-overlay');
+    overlay.classList.add('visible');
+    // Initialize the UI elements
+    initializeUI();
+    generateOpenAIImage(apiKey, prompt);
+});

+ 1378 - 0
static/js/image2sand.js

@@ -0,0 +1,1378 @@
+/*
+ * Image2Sand - Convert images to sand table coordinates
+ * 
+ * This script processes images and converts them to polar coordinates
+ * according to the specified settings, supporting multiple output formats including:
+ *  Default: HackPack Sand Garden .ino in this repository
+ *  theta-rho format: for use with sand tables like Sisyphus and Dune Weaver Mini.
+ * 
+ * Note:
+ *  For Dune Weaver Mini compatibility, this script uses continuous theta values
+ *  that can exceed 2π (360 degrees). This allows the arm to make multiple revolutions
+ *  without creating unintended circles in the patterns.
+ */
+
+class PriorityQueue {
+    constructor() {
+        this.heap = [];
+    }
+
+    enqueue(priority, key) {
+        this.heap.push({ key, priority });
+        this._bubbleUp(this.heap.length - 1);
+    }
+
+    dequeue() {
+        const min = this.heap[0];
+        const end = this.heap.pop();
+        
+        if (this.heap.length > 0) {
+            this.heap[0] = end;
+            this._sinkDown(0);
+        }
+        
+        return min;
+    }
+
+    isEmpty() {
+        return this.heap.length === 0;
+    }
+
+    _bubbleUp(index) {
+        const element = this.heap[index];
+        
+        while (index > 0) {
+            const parentIndex = Math.floor((index - 1) / 2);
+            const parent = this.heap[parentIndex];
+            
+            if (element.priority >= parent.priority) break;
+            
+            this.heap[parentIndex] = element;
+            this.heap[index] = parent;
+            index = parentIndex;
+        }
+    }
+
+    _sinkDown(index) {
+        const length = this.heap.length;
+        const element = this.heap[index];
+        
+        while (true) {
+            const leftChildIndex = 2 * index + 1;
+            const rightChildIndex = 2 * index + 2;
+            let smallestChildIndex = null;
+            
+            if (leftChildIndex < length) {
+                if (this.heap[leftChildIndex].priority < element.priority) {
+                    smallestChildIndex = leftChildIndex;
+                }
+            }
+            
+            if (rightChildIndex < length) {
+                if (
+                    (smallestChildIndex === null && this.heap[rightChildIndex].priority < element.priority) ||
+                    (smallestChildIndex !== null && this.heap[rightChildIndex].priority < this.heap[leftChildIndex].priority)
+                ) {
+                    smallestChildIndex = rightChildIndex;
+                }
+            }
+            
+            if (smallestChildIndex === null) break;
+            
+            this.heap[index] = this.heap[smallestChildIndex];
+            this.heap[smallestChildIndex] = element;
+            index = smallestChildIndex;
+        }
+    }
+}
+
+
+let currentContourIndex = 0;
+let isFirstClick = true;
+let originalImageElement = null;
+let isGeneratingImage = false;
+let isGeneratingCoords = false;
+
+function drawAndPrepImage(imgElement) {
+    const canvas = document.getElementById('original-image');
+    const ctx = canvas.getContext('2d');
+    canvas.width = imgElement.naturalWidth;
+    canvas.height = imgElement.naturalHeight;
+    ctx.drawImage(imgElement, 0, 0);
+
+    // Set originalImageElement to the current image
+    originalImageElement = imgElement;
+
+    // Enable Generate button
+    document.getElementById('generate-button').disabled = false;
+}
+
+
+async function generateImage(apiKey, prompt, autoprocess) {
+    if (isGeneratingImage) {
+        document.getElementById('generation-status').textContent = "Image is still generating - please don't press the button."; 
+    } else {
+        isGeneratingImage = true;
+        document.getElementById('gen-image-button').disabled = true;
+        document.getElementById('generation-status').style.display = 'block';
+        try {
+
+            const fullPrompt = `Draw an image of the following: ${prompt}. But make it a simple black silhouette on a white background, with very minimal detail and no additional content in the image, so I can use it for a computer icon.`;
+
+            const response = await fetch('https://api.openai.com/v1/images/generations', {
+                method: 'POST',
+                headers: {
+                    'Authorization': `Bearer ${apiKey}`,
+                    'Content-Type': 'application/json'
+                },
+                body: JSON.stringify({
+                    model: 'dall-e-3',
+                    prompt: fullPrompt,
+                    size: '1024x1024',
+                    quality: 'standard',
+                    response_format: 'b64_json', // Specify base64 encoding
+                    n: 1
+                })
+            });
+
+            const data = await response.json();
+            //const imageUrl = data.data[0].url;
+            if ('error' in data) {
+                throw new Error(data.error.message);
+            }
+            const imageData = data.data[0].b64_json;
+
+            console.log("Image Data: ", imageData);
+
+            const imgElement = new Image();
+            imgElement.onload = function() {
+                drawAndPrepImage(imgElement);
+                if (autoprocess) {
+                    convertImage();
+                }
+            };
+            imgElement.src = `data:image/png;base64,${imageData}`;
+
+            console.log(`Image generated successfully`);
+        } catch (error) {
+            console.error('Image generation error:', error);
+        }
+        isGeneratingImage = false;
+        document.getElementById('generation-status').style.display = 'none';
+        document.getElementById('generation-status').textContent = "Image is generating - please wait...";
+        document.getElementById('gen-image-button').disabled = false;
+    }
+
+}
+
+
+function handleImageUpload(event) {
+    const file = event.target.files[0];
+    const reader = new FileReader();
+
+    reader.onload = e => {
+        if (!originalImageElement) {
+            originalImageElement = new Image();
+            originalImageElement.id = 'uploaded-image';
+            originalImageElement.onload = () => {
+                drawAndPrepImage(originalImageElement);
+            };
+            document.getElementById('original-image').appendChild(originalImageElement);
+        }
+        originalImageElement.src = e.target.result;
+    };
+
+    reader.readAsDataURL(file);
+}
+
+
+function processImage(imgElement) {
+    document.getElementById('processing-status').style.display = 'block';
+    document.getElementById('generate-button').disabled = true;
+
+    // Use setTimeout to allow the UI to update
+    setTimeout(() => {
+        const src = cv.imread(imgElement), dst = new cv.Mat();
+        cv.cvtColor(src, src, cv.COLOR_RGBA2GRAY, 0);
+        cv.Canny(src, dst, 50, 150, 3, false);
+
+        // Add morphological operations
+        const kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3));
+        cv.dilate(dst, dst, kernel);
+        cv.erode(dst, dst, kernel);
+        // Invert the colors of the detected edges image
+        cv.bitwise_not(dst, dst);
+
+        // Ensure edge-image canvas has the same dimensions as the original image
+        const edgeCanvas = document.getElementById('edge-image');
+        edgeCanvas.width = imgElement.naturalWidth;
+        edgeCanvas.height = imgElement.naturalHeight;
+
+        cv.imshow('edge-image', dst);
+        cv.bitwise_not(dst, dst);
+        generateDots(dst);
+        src.delete(); dst.delete();
+
+        // Hide the processing status label
+        document.getElementById('processing-status').style.display = 'none';
+        document.getElementById('generate-button').disabled = false;
+    }, 0); // Set delay to 0 to allow the UI to update
+
+    // Ensure grid height does not exceed 70% of the viewport height
+    const gridHeight = document.querySelector('.grid').clientHeight;
+    const viewportHeight = window.innerHeight * 0.7;
+    if (gridHeight > viewportHeight) {
+        document.querySelector('.grid').style.height = `${viewportHeight}px`;
+    }
+}
+
+
+function plotNextContour() {
+    const canvas = document.getElementById('plotcontours');
+    const ctx = canvas.getContext('2d');
+
+    if (isFirstClick) {
+        // Clear the canvas on first click
+        ctx.clearRect(0, 0, canvas.width, canvas.height);
+        isFirstClick = false;
+    }
+
+    console.log('Cur Contour: ', currentContourIndex + '/' + orderedContoursSave.length + ":", JSON.stringify(orderedContoursSave[currentContourIndex]));
+
+    if (currentContourIndex < orderedContoursSave.length) {
+        const contour = orderedContoursSave[currentContourIndex];
+        const baseColor = getRandomColor();
+        const [r, g, b] = hexToRgb(baseColor);
+        const length = contour.length;
+
+        contour.forEach((point, i) => {
+            if (i === 0) {
+                ctx.beginPath();
+                ctx.moveTo(point.x, point.y);
+            } else {
+                ctx.lineTo(point.x, point.y);
+
+                // Calculate color fade
+                const ratio = i / length;
+                const fadedColor = `rgb(${Math.round(r * (1 - ratio))}, ${Math.round(g * (1 - ratio))}, ${Math.round(b * (1 - ratio))})`;
+                ctx.strokeStyle = fadedColor;
+                ctx.lineWidth = 2;
+                ctx.stroke();
+                ctx.beginPath();
+                ctx.moveTo(point.x, point.y);
+            }
+        });
+
+        // Mark the start and end points
+        ctx.fillStyle = baseColor;
+        ctx.font = '12px Arial';
+
+        // Start point
+        ctx.fillText(`S${currentContourIndex + 1}`, contour[0].x, contour[0].y);
+        ctx.beginPath();
+        ctx.arc(contour[0].x, contour[0].y, 3, 0, 2 * Math.PI);
+        ctx.fill();
+
+        // End point
+        ctx.fillText(`E${currentContourIndex + 1}`, contour[contour.length - 1].x, contour[contour.length - 1].y);
+        ctx.beginPath();
+        ctx.arc(contour[contour.length - 1].x, contour[contour.length - 1].y, 3, 0, 2 * Math.PI);
+        ctx.fill();
+
+        // Label the contour with its number
+        const midPoint = contour[Math.floor(contour.length / 2)];
+        ctx.fillText(`${currentContourIndex + 1}`, midPoint.x, midPoint.y);
+
+        // Increment the contour index
+        currentContourIndex++;
+    } else {
+        alert("All contours have been plotted. Starting over.");
+        currentContourIndex = 0; // Reset the index
+        isFirstClick = true; // Reset the first click flag
+    }
+}
+
+
+function findNearestPoint(lastPoint, contours, visitedPoints) {
+    let nearestPoint = null;
+    let nearestDistance = Infinity;
+
+    contours.forEach(contour => {
+        contour.forEach(point => {
+            if (!point || visitedPoints.has(JSON.stringify(point))) return;
+
+            const distance = Math.sqrt(
+                Math.pow(lastPoint.x - point.x, 2) + 
+                Math.pow(lastPoint.y - point.y, 2)
+            );
+
+            if (distance < nearestDistance) {
+                nearestDistance = distance;
+                nearestPoint = point;
+            }
+        });
+    });
+
+    return nearestPoint;
+}
+
+
+function findMaximalCenter(points) {
+    const minX = Math.min(...points.map(p => p.x));
+    const maxX = Math.max(...points.map(p => p.x));
+    const minY = Math.min(...points.map(p => p.y));
+    const maxY = Math.max(...points.map(p => p.y));
+
+    const centerX = (minX + maxX) / 2;
+    const centerY = (minY + maxY) / 2;
+
+    const width = maxX - minX;
+    const height = maxY - minY;
+
+    return { centerX, centerY, width, height };
+}
+
+
+function calculateCentroid(points) {
+    let sumX = 0, sumY = 0;
+    points.forEach(p => {
+        sumX += p.x;
+        sumY += p.y;
+    });
+    return { x: sumX / points.length, y: sumY / points.length };
+}
+
+
+function calculateDistances(contours) {
+    const distances = [];
+
+    for (let i = 0; i < contours.length; i++) {
+        distances[i] = [];
+        for (let j = 0; j < contours.length; j++) {
+            if (i !== j) {
+                const startToStart = Math.hypot(contours[i][0].x - contours[j][0].x, contours[i][0].y - contours[j][0].y);
+                const startToEnd = Math.hypot(contours[i][0].x - contours[j][contours[j].length - 1].x, contours[i][0].y - contours[j][contours[j].length - 1].y);
+                const endToStart = Math.hypot(contours[i][contours[i].length - 1].x - contours[j][0].x, contours[i][contours[i].length - 1].y - contours[j][0].y);
+                const endToEnd = Math.hypot(contours[i][contours[i].length - 1].x - contours[j][contours[j].length - 1].x, contours[i][contours[i].length - 1].y - contours[j][contours[j].length - 1].y);
+                distances[i][j] = Math.min(startToStart, startToEnd, endToStart, endToEnd);
+            }
+        }
+    }
+
+    return distances;
+}
+
+
+function tspNearestNeighbor(distances, contours) {
+    const path = [0];
+    const visited = new Set([0]);
+
+    while (path.length < contours.length) {
+        let last = path[path.length - 1];
+        let nearest = -1;
+        let nearestDistance = Infinity;
+
+        for (let i = 0; i < contours.length; i++) {
+            if (!visited.has(i) && distances[last][i] < nearestDistance) {
+                nearestDistance = distances[last][i];
+                nearest = i;
+            }
+        }
+
+        if (nearest !== -1) {
+            path.push(nearest);
+            visited.add(nearest);
+        }
+    }
+
+    return path;
+}
+
+
+function reorderContours(contours, path) {
+    const orderedContours = [];
+    console.log("Path:", path); // Debugging
+
+    for (let i = 0; i < path.length; i++) {
+        const contourIndex = path[i];
+        let contour = contours[contourIndex];
+
+        // Determine the direction to use the contour
+        if (i > 0) {
+            const prevContour = orderedContours[orderedContours.length - 1];
+            const prevPoint = prevContour[prevContour.length - 1];
+            
+            if (isFullyClosed(contour)) {
+                // Contour is fully closed, so can move the startPoint
+                contour = reorderPointsForLoop(contour, startNear = prevPoint)
+            } else if (prevPoint && contour[0]) {
+                // Contour not fully closed, decide whether to reverse contour
+                const startToStart = Math.hypot(prevPoint.x - contour[0].x, prevPoint.y - contour[0].y);
+                const startToEnd = Math.hypot(prevPoint.x - contour[contour.length - 1].x, prevPoint.y - contour[contour.length - 1].y);
+
+                if (startToEnd < startToStart) {
+                    contour.reverse();
+                }
+            } else {
+                console.error('Previous point or current contour start point is undefined.', { prevPoint, currentStart: contour[0] });
+                continue; // Skip if any point is undefined
+            }
+        }
+
+        orderedContours.push(contour);
+    }
+
+    return orderedContours;
+}
+
+
+function findClosestPoint(contours, point) {
+    let minDistance = Infinity;
+    let closestPoint = null;
+
+    contours.forEach(contour => {
+        contour.forEach(pt => {
+            const distance = Math.hypot(point.x - pt.x, point.y - pt.y);
+            if (distance < minDistance) {
+                minDistance = distance;
+                closestPoint = pt;
+            }
+        });
+    });
+    return closestPoint;
+}
+
+
+function createGraphWithConnectionTypes(contours) {
+    const graph = [];
+    const nodeMap = new Map(); // Map to quickly find nodes by coordinates
+    const MAX_JUMP_CONNECTIONS = 10; // Limit the number of jump connections per node
+
+    // Create nodes for each point in the contours
+    contours.forEach(contour => {
+        contour.forEach(pt => {
+            const key = `${pt.x},${pt.y}`;
+            if (!nodeMap.has(key)) {
+                const node = { x: pt.x, y: pt.y, neighbors: [] };
+                graph.push(node);
+                nodeMap.set(key, node);
+            }
+        });
+    });
+
+    // Connect points within the same contour (regular path connections)
+    contours.forEach(contour => {
+        for (let i = 0; i < contour.length; i++) {
+            const key = `${contour[i].x},${contour[i].y}`;
+            const node = nodeMap.get(key);
+            
+            if (i > 0) {
+                const prevKey = `${contour[i - 1].x},${contour[i - 1].y}`;
+                const prevNode = nodeMap.get(prevKey);
+                
+                if (!node.neighbors.some(neighbor => neighbor.node === prevNode)) {
+                    node.neighbors.push({ node: prevNode, isJump: false });
+                    prevNode.neighbors.push({ node: node, isJump: false });
+                }
+            }
+        }
+    });
+
+    // Create a spatial index for efficient nearest neighbor search
+    const spatialIndex = [];
+    graph.forEach(node => {
+        spatialIndex.push({
+            node: node,
+            x: node.x,
+            y: node.y
+        });
+    });
+
+    // Connect nodes from different contours with jump connections, but limit the number
+    graph.forEach(nodeA => {
+        // Sort other nodes by distance to current node
+        const distances = spatialIndex
+            .filter(item => item.node !== nodeA)
+            .map(item => ({
+                node: item.node,
+                distance: Math.hypot(nodeA.x - item.node.x, nodeA.y - item.node.y)
+            }))
+            .sort((a, b) => a.distance - b.distance);
+        
+        // Only connect to the closest MAX_JUMP_CONNECTIONS nodes
+        distances.slice(0, MAX_JUMP_CONNECTIONS).forEach(({ node: nodeB, distance }) => {
+            if (!nodeA.neighbors.some(neighbor => neighbor.node === nodeB)) {
+                nodeA.neighbors.push({ node: nodeB, isJump: true, jumpDistance: distance });
+            }
+            if (!nodeB.neighbors.some(neighbor => neighbor.node === nodeA)) {
+                nodeB.neighbors.push({ node: nodeA, isJump: true, jumpDistance: distance });
+            }
+        });
+    });
+
+    return graph;
+}
+
+
+function addStartEndToGraph(graph, start, end) {
+    const nodeMap = new Map();
+    const MAX_CONNECTIONS = 10; // Limit the number of connections from start/end points
+    
+    // Create a map for faster node lookups
+    graph.forEach((node, index) => {
+        nodeMap.set(`${node.x},${node.y}`, { node, index });
+    });
+    
+    // Check if start and end points already exist in the graph
+    const startKey = `${start.x},${start.y}`;
+    const endKey = `${end.x},${end.y}`;
+    
+    let startIdx = nodeMap.has(startKey) ? nodeMap.get(startKey).index : graph.length;
+    let endIdx = nodeMap.has(endKey) ? nodeMap.get(endKey).index : (startIdx === graph.length ? graph.length + 1 : graph.length);
+    
+    // Add start point if it doesn't exist
+    if (!nodeMap.has(startKey)) {
+        const startNode = { x: start.x, y: start.y, neighbors: [] };
+        graph.push(startNode);
+        nodeMap.set(startKey, { node: startNode, index: startIdx });
+    }
+    
+    // Add end point if it doesn't exist
+    if (!nodeMap.has(endKey)) {
+        const endNode = { x: end.x, y: end.y, neighbors: [] };
+        graph.push(endNode);
+        nodeMap.set(endKey, { node: endNode, index: endIdx });
+    }
+    
+    // Find the closest nodes to connect to start and end
+    const startNode = graph[startIdx];
+    const endNode = graph[endIdx];
+    
+    // Calculate distances from start to all other nodes
+    const startDistances = [];
+    graph.forEach((node, idx) => {
+        if (idx !== startIdx) {
+            const distance = Math.hypot(start.x - node.x, start.y - node.y);
+            startDistances.push({ node, idx, distance });
+        }
+    });
+    
+    // Sort by distance and connect only to the closest MAX_CONNECTIONS nodes
+    startDistances.sort((a, b) => a.distance - b.distance);
+    startDistances.slice(0, MAX_CONNECTIONS).forEach(({ node, idx, distance }) => {
+        startNode.neighbors.push({ node, isJump: true, jumpDistance: distance });
+        node.neighbors.push({ node: startNode, isJump: true, jumpDistance: distance });
+    });
+    
+    // Calculate distances from end to all other nodes
+    const endDistances = [];
+    graph.forEach((node, idx) => {
+        if (idx !== endIdx) {
+            const distance = Math.hypot(end.x - node.x, end.y - node.y);
+            endDistances.push({ node, idx, distance });
+        }
+    });
+    
+    // Sort by distance and connect only to the closest MAX_CONNECTIONS nodes
+    endDistances.sort((a, b) => a.distance - b.distance);
+    endDistances.slice(0, MAX_CONNECTIONS).forEach(({ node, idx, distance }) => {
+        endNode.neighbors.push({ node, isJump: true, jumpDistance: distance });
+        node.neighbors.push({ node: endNode, isJump: true, jumpDistance: distance });
+    });
+    
+    return { startIdx, endIdx };
+}
+
+
+function dijkstraWithMinimalJumps(graph, startIdx, endIdx) {
+    const distances = Array(graph.length).fill(Infinity);
+    const previous = Array(graph.length).fill(null);
+    const totalJumpDistances = Array(graph.length).fill(Infinity);
+    const priorityQueue = new PriorityQueue();
+    const nodeIndices = new Map(); // Map to quickly find node indices
+    
+    // Create a map of node coordinates to indices for faster lookups
+    graph.forEach((node, index) => {
+        nodeIndices.set(`${node.x},${node.y}`, index);
+    });
+
+    distances[startIdx] = 0;
+    totalJumpDistances[startIdx] = 0;
+    priorityQueue.enqueue(0, startIdx);
+
+    while (!priorityQueue.isEmpty()) {
+        const { key: minDistanceNode } = priorityQueue.dequeue();
+
+        if (minDistanceNode === endIdx) break;
+
+        const currentNode = graph[minDistanceNode];
+        currentNode.neighbors.forEach(neighbor => {
+            // Use the map for faster node index lookup
+            const neighborKey = `${neighbor.node.x},${neighbor.node.y}`;
+            const neighborIdx = nodeIndices.get(neighborKey);
+            
+            const jumpDistance = neighbor.isJump ? neighbor.jumpDistance : 0;
+            const alt = distances[minDistanceNode] + Math.hypot(currentNode.x - neighbor.node.x, currentNode.y - neighbor.node.y);
+            const totalJumpDist = totalJumpDistances[minDistanceNode] + jumpDistance;
+
+            if (totalJumpDist < totalJumpDistances[neighborIdx] || (totalJumpDist === totalJumpDistances[neighborIdx] && alt < distances[neighborIdx])) {
+                distances[neighborIdx] = alt;
+                previous[neighborIdx] = minDistanceNode;
+                totalJumpDistances[neighborIdx] = totalJumpDist;
+                priorityQueue.enqueue(totalJumpDist, neighborIdx);
+            }
+        });
+    }
+
+    const path = [];
+    let u = endIdx;
+
+    while (u !== null) {
+        path.unshift({ x: graph[u].x, y: graph[u].y });
+        u = previous[u];
+    }
+
+    return path;
+}
+
+
+function adjustEpsilon(epsilon, pointsOver) {
+    if (pointsOver > 100) {
+        return epsilon + 0.5;
+    } else if (pointsOver <= 20) {
+        return epsilon + 0.1;
+    } else {
+        // Scale adjustment for points over the target between 20 and 100
+        let scale = (pointsOver - 20) / (100 - 20); // Normalized to range 0-1
+        return epsilon + 0.1 + 0.5 * scale;  // Adjust between 0.1 and 0.5
+    }
+}
+
+// Checks if a contour is nearly closed
+function isNearlyClosed(contour, percentThreshold = 0.1) {
+    // Get the bounding box of the contour
+    const rect = cv.boundingRect(contour);
+    const size = Math.sqrt(rect.width * rect.width + rect.height * rect.height);
+
+    // Calculate the distance between the first and last points
+    const startPoint = { x: contour.intPtr(0)[0], y: contour.intPtr(0)[1] };
+    const endPoint = { x: contour.intPtr(contour.rows - 1)[0], y: contour.intPtr(contour.rows - 1)[1] };
+    const distance = Math.sqrt((startPoint.x - endPoint.x) ** 2 + (startPoint.y - endPoint.y) ** 2);
+
+    // Use a threshold based on the size of the object
+    const threshold = size * percentThreshold;
+    return (distance < threshold);
+}
+
+// Check if contour is fully closed
+function isContourClosed(contour) {
+    // Calculate the distance between the first and last points
+    const startPoint = { x: contour.intPtr(0)[0], y: contour.intPtr(0)[1] };
+    const endPoint = { x: contour.intPtr(contour.rows - 1)[0], y: contour.intPtr(contour.rows - 1)[1] };
+    
+    return (startPoint === endPoint);
+}
+
+// Checks if a PointList has the same first and last point
+function isFullyClosed(points) {
+    return ((points[0].x === points[points.length - 1].x) && (points[0].y === points[points.length - 1].y));
+}
+
+// Closes a contour by adding the first point at the end
+function closeContour(points) {
+    if (points.length > 1 && (points[0].x !== points[points.length - 1].x || points[0].y !== points[points.length - 1].y)) {
+        points.push({ x: points[0].x, y: points[0].y });
+    }
+    return points;
+}
+
+function areContoursSimilar(contour1, contour2, similarityThreshold) {
+    // Calculate the bounding boxes of the contours
+    const rect1 = cv.boundingRect(contour1);
+    const rect2 = cv.boundingRect(contour2);
+
+    // Calculate the intersection of the bounding boxes
+    const x1 = Math.max(rect1.x, rect2.x);
+    const y1 = Math.max(rect1.y, rect2.y);
+    const x2 = Math.min(rect1.x + rect1.width, rect2.x + rect2.width);
+    const y2 = Math.min(rect1.y + rect1.height, rect2.y + rect2.height);
+
+    // Check if there is an intersection
+    const intersectionWidth = Math.max(0, x2 - x1);
+    const intersectionHeight = Math.max(0, y2 - y1);
+    const intersectionArea = intersectionWidth * intersectionHeight;
+
+    // Calculate the union of the bounding boxes
+    const area1 = rect1.width * rect1.height;
+    const area2 = rect2.width * rect2.height;
+    const unionArea = area1 + area2 - intersectionArea;
+
+    // Calculate the similarity based on the intersection over union (IoU)
+    const similarity = intersectionArea / unionArea;
+
+    return similarity > similarityThreshold;
+}
+
+function deduplicateContours(contours, similarityThreshold = 0.5) {
+    const uniqueContours = [];
+    for (let i = 0; i < contours.size(); i++) {
+        const contour = contours.get(i);
+        let isDuplicate = false;
+        for (let j = 0; j < uniqueContours.length; j++) {
+            if (areContoursSimilar(contour, uniqueContours[j], similarityThreshold)) {
+                isDuplicate = true;
+                break;
+            }
+        }
+        if (!isDuplicate) {
+            uniqueContours.push(contour);
+        }
+    }
+    return uniqueContours;
+}
+
+// Function to interpolate points along a straight line
+function interpolatePoints(startPoint, endPoint, numPoints) {
+    if (numPoints <= 2) return [startPoint, endPoint];
+    
+    const points = [];
+    for (let i = 0; i < numPoints; i++) {
+        const t = i / (numPoints - 1);
+        const x = startPoint.x + t * (endPoint.x - startPoint.x);
+        const y = startPoint.y + t * (endPoint.y - startPoint.y);
+        points.push({ x, y });
+    }
+    return points;
+}
+
+// Function to calculate the distance between two points
+function distanceBetweenPoints(p1, p2) {
+    return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
+}
+
+// Function to add interpolated points to a contour based on segment length
+function addInterpolatedPoints(points, epsilon) {
+    if (points.length <= 1) return points;
+    
+    const result = [];
+    for (let i = 0; i < points.length - 1; i++) {
+        const startPoint = points[i];
+        const endPoint = points[i + 1];
+        
+        // Calculate distance between points
+        const distance = distanceBetweenPoints(startPoint, endPoint);
+        
+        // Determine how many points to add based on distance and epsilon
+        // For longer segments and smaller epsilon values, we want more points
+        // The smaller the epsilon, the more detailed the contour, so we add more points
+        const pointsToAdd = Math.max(2, Math.ceil(distance / (epsilon * 5)));
+        
+        // Add interpolated points for this segment
+        const interpolated = interpolatePoints(startPoint, endPoint, pointsToAdd);
+        
+        // Add all points except the last one (to avoid duplicates)
+        if (i < points.length - 2) {
+            result.push(...interpolated.slice(0, -1));
+        } else {
+            // For the last segment, include the end point
+            result.push(...interpolated);
+        }
+    }
+    
+    return result;
+}
+
+function getOrderedContours(edgeImage, initialEpsilon, retrievalMode, maxPoints) {
+    const contours = new cv.MatVector(), hierarchy = new cv.Mat();
+    cv.findContours(edgeImage, contours, hierarchy, retrievalMode, cv.CHAIN_APPROX_SIMPLE);
+    console.log("# Contours: ", contours.size());
+
+    // Deduplicate contours
+    const uniqueContours = deduplicateContours(contours);
+
+    const maxIterations = 100;  // Maximum iterations to avoid infinite loop
+
+    let contourPoints = [];
+    let totalPoints = 0;
+    let epsilon = initialEpsilon;
+    let iterations = 0;
+
+    do {
+        totalPoints = 0;
+        contourPoints = [];
+
+        for (let i = 0; i < uniqueContours.length; i++) {
+            const contour = uniqueContours[i]; // Use [] to access array elements
+            const simplified = new cv.Mat();
+            cv.approxPolyDP(contour, simplified, epsilon, true);
+        
+            let points = [];
+            for (let j = 0; j < simplified.rows; j++) {
+                const point = simplified.intPtr(j);
+                points.push({ x: point[0], y: point[1] });
+            }
+            simplified.delete();
+        
+            if (points.length > 0) {  // Check for empty contours
+                if (isNearlyClosed(contour)) {  // Only close the contour if it's nearly closed
+                    points = closeContour(points);
+                }
+                
+                // We no longer interpolate points here - we'll do it later only if needed
+                
+                if (isFullyClosed(points)) {
+                    // Move starting point to nearest the center
+                    points = reorderPointsForLoop(points);
+                }
+                contourPoints.push(points);
+                totalPoints += points.length;
+            }
+        }        
+
+        if (totalPoints > maxPoints) {
+            let pointsOver = totalPoints - maxPoints;
+            epsilon = adjustEpsilon(epsilon, pointsOver);
+            iterations++;
+        }
+    } while (totalPoints > maxPoints && iterations < maxIterations);
+
+    if (totalPoints > maxPoints && iterations >= maxIterations) {
+        let flattenedPoints = contourPoints.flat();
+        contourPoints = [flattenedPoints.slice(0, maxPoints)];  // Take the first N points
+    }
+
+    if (contourPoints.length === 0) {
+        console.error("No valid contours found.");
+        return [];
+    }
+
+    // Calculate distances and find the best path
+    const distances = calculateDistances(contourPoints);
+    const path = tspNearestNeighbor(distances, contourPoints);
+    const orderedContours = reorderContours(contourPoints, path);
+
+    return orderedContours;
+}
+
+
+function getRandomColor() {
+    const letters = '0123456789ABCDEF';
+    let color = '#';
+    for (let i = 0; i < 6; i++) {
+        color += letters[Math.floor(Math.random() * 16)];
+    }
+    return color;
+}
+
+
+function resetCanvas(canvasId) {
+    const canvas = document.getElementById(canvasId);
+    const ctx = canvas.getContext('2d');
+    
+    // Store current dimensions
+    const width = canvas.width;
+    const height = canvas.height;
+    
+    // Clear the canvas
+    ctx.clearRect(0, 0, width, height);
+    
+    // Reset transformation matrix
+    ctx.setTransform(1, 0, 0, 1, 0, 0);
+    
+    // Ensure dimensions are maintained
+    canvas.width = width;
+    canvas.height = height;
+}
+
+
+function traceContours(orderedContours, isLoop = false, minimizeJumps = true) {
+    let result = [];
+    let pathsUsed = [...orderedContours];
+   
+    for (let i = 0; i < orderedContours.length - (isLoop ? 0 : 1); i++) {
+        const currentContour = orderedContours[i];
+        
+        // If looping, add 1st contour again
+        const nextContour = orderedContours[(i + 1) % orderedContours.length];
+        const start = currentContour[currentContour.length - 1];  // End of the current contour
+        const end = nextContour[0];  // Start of the next contour
+
+        let path = [];
+        if (minimizeJumps){
+            // Find path between contours
+            path = findPathWithMinimalJumpDistances(pathsUsed, start, end);
+        }
+        
+        result.push(currentContour);
+        if (path.length > 0) {  // Add the path only if it has points
+            result.push(path);
+            pathsUsed.push(path);  // Add the used path to the list of paths
+            //console.log('Added Path: ', i, JSON.stringify(path));
+        } else {
+            //console.log('No Path Needed', i)
+        }
+    }
+
+    // If not looping, add the last contour as it doesn't need a connecting path and wasn't added above
+    if (!isLoop) { result.push(orderedContours[orderedContours.length - 1]); }
+
+    return result;
+}
+
+
+function removeConsecutiveDuplicates(points) {
+    return points.filter((point, index) => {
+        if (index === 0) return true; // Keep the first point
+        const prevPoint = points[index - 1];
+        return !(point.x === prevPoint.x && point.y === prevPoint.y);
+    });
+}
+
+
+function findPathWithMinimalJumpDistances(contours, start, end) {
+    const graph = createGraphWithConnectionTypes(contours);
+    const { startIdx, endIdx } = addStartEndToGraph(graph, start, end);
+    const path = dijkstraWithMinimalJumps(graph, startIdx, endIdx);
+    return path;
+}
+
+
+function generateDots(edgeImage) {
+    // Reset the canvas before drawing the new image
+    resetCanvas('dot-image');
+    resetCanvas('connect-image');
+    
+    // Ensure canvases have the same dimensions
+    const dotCanvas = document.getElementById('dot-image');
+    const connectCanvas = document.getElementById('connect-image');
+    const originalCanvas = document.getElementById('original-image');
+    const edgeCanvas = document.getElementById('edge-image');
+    
+    if (originalImageElement) {
+        // Set all canvases to the same dimensions
+        dotCanvas.width = originalImageElement.naturalWidth;
+        dotCanvas.height = originalImageElement.naturalHeight;
+        connectCanvas.width = originalImageElement.naturalWidth;
+        connectCanvas.height = originalImageElement.naturalHeight;
+    }
+
+    const epsilon = parseFloat(document.getElementById('epsilon-slider').value),
+        contourMode = document.getElementById('contour-mode').value,
+        isLoop = document.getElementById('is-loop').checked,
+        minimizeJumps = document.getElementById('no-shortcuts').checked,
+        outputFormat = parseInt(document.getElementById('output-type').value),
+        maxPoints = parseInt(document.getElementById('dot-number').value);
+        // useGaussianBlur = document.getElementById('gaussian-blur-toggle').checked,
+    const retrievalMode = (contourMode == 'External') ?  cv.RETR_EXTERNAL : cv.RETR_TREE;    
+    
+    orderedContours = getOrderedContours(edgeImage, epsilon, retrievalMode, maxPoints);
+
+    console.log('Ordered Contours: ', JSON.stringify(orderedContours));
+
+    const tracedContours = traceContours(orderedContours, isLoop, minimizeJumps);
+    console.log('Traced: ', JSON.stringify(tracedContours));
+
+    // Only apply additional interpolation if .thr format (format 2) is selected
+    let processedContours;
+    if (outputFormat === 2) {
+        // Apply interpolation for .thr format which needs more points for straight lines
+        processedContours = tracedContours.map(contour => 
+            addInterpolatedPoints(contour, epsilon)
+        );
+    } else {
+        processedContours = tracedContours;
+    }
+
+    plotContours(processedContours);
+    // Save for future plotting
+    orderedContoursSave = processedContours;
+
+    let orderedPoints = processedContours.flat();
+
+    // Should always be the case for isLoop
+    if (isFullyClosed(orderedPoints) || isLoop) {
+        orderedPoints = reorderPointsForLoop(orderedPoints);
+    }
+
+    orderedPoints = removeConsecutiveDuplicates(orderedPoints);
+
+    // For final output - if last point is same as first point, drop it.
+    if (isFullyClosed(orderedPoints)) {
+        orderedPoints = [...orderedPoints.slice(0,orderedPoints.length-1)];
+    }
+
+    const polarPoints = drawDots(orderedPoints);
+    WriteCoords(polarPoints, outputFormat);
+    drawConnections(polarPoints);
+    document.getElementById('total-points').innerText = `(${orderedPoints.length} Points)`;
+}
+
+
+function WriteCoords(polarPoints, outputFormat = 0){
+    let formattedPolarPoints = '';
+    switch (outputFormat) {
+        case 0: //Default
+            // For Image2Sand.ino code, we normalize the theta values
+            // We'll use modulo for this format
+            formattedPolarPoints = polarPoints.map(p => {
+            const normalizedTheta = ((p.theta % 3600) + 3600) % 3600; // Ensure positive value between 0-3600
+            return `{${p.r.toFixed(0)},${normalizedTheta.toFixed(0)}}`;
+        }).join(',');
+            break;
+
+        case 1: //Single Byte
+            // For single byte format, we need to normalize the theta values
+            // We'll use modulo for this format since it's just for Arduino code
+            formattedPolarPoints = polarPoints.map(p => {
+                const normalizedTheta = ((p.theta % 3600) + 3600) % 3600; // Ensure positive value between 0-3600
+                return `{${Math.round(255 * p.r / 1000)},${Math.round(255 * normalizedTheta / 3600)}}`;
+            }).join(',');
+            break;
+
+        case 2: //.thr
+            // For .thr format, we keep the continuous theta values
+            // Convert from tenths of degrees back to radians
+            // Apply a 90° clockwise rotation by subtracting π/2 (900 in tenths of degrees) from theta
+            formattedPolarPoints = polarPoints.map(p => {
+                // Subtract 900 (90 degrees) to rotate clockwise
+                const rotatedTheta = p.theta - 900;
+                return `${(-rotatedTheta * Math.PI / 1800).toFixed(5)} ${(p.r / 1000).toFixed(5)}`;
+            }).join("\n");
+            break;
+
+        case 3: // whitespace (might cause problems as it outputs a space)
+            // For whitespace format, we need to normalize the theta values
+            // We'll use modulo for this format
+            formattedPolarPoints = polarPoints.map(p => {
+                const normalizedTheta = ((p.theta % 3600) + 3600) % 3600; // Ensure positive value between 0-3600
+                return `${Math.round(255 * p.r / 1000).toString(2).padStart(8,'0').replaceAll('0',' ').replaceAll('1',"\t")}${Math.round(255 * normalizedTheta / 3600).toString(2).padStart(8,'0').replaceAll('0',' ').replaceAll('1',"\t")}`;
+            }).join("\n");
+            break;
+
+        default: 
+            break;
+    }
+   
+    document.getElementById('polar-coordinates-textarea').value = formattedPolarPoints;
+    document.getElementById('simple-coords').textContent = formattedPolarPoints;
+    document.getElementById('simple-coords-title').style = 'visibility: hidden';
+}
+
+
+function reorderPointsForLoop(points, startNear = calculateCentroid(points)) {
+    let minDist = Infinity;
+    let startIndex = 0;
+
+    // Find the point nearest to the centroid
+    points.forEach((point, index) => {
+        const dist = Math.hypot(point.x - startNear.x, point.y - startNear.y);
+        if (dist < minDist) {
+            minDist = dist;
+            startIndex = index;
+        }
+    });
+
+    // Reorder points to start from the point nearest to the centroid
+    return removeConsecutiveDuplicates([...points.slice(startIndex), ...points.slice(0, startIndex+1)]);
+    
+}
+
+
+function drawDots(points) {
+    const canvas = document.getElementById('dot-image'), ctx = canvas.getContext('2d');
+    
+    // Set canvas dimensions to match the original image
+    if (originalImageElement) {
+        canvas.width = originalImageElement.naturalWidth;
+        canvas.height = originalImageElement.naturalHeight;
+    }
+    
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+    const width = canvas.width, height = canvas.height;
+    const scaleX = width / originalImageElement.width;
+    const scaleY = height / originalImageElement.height;
+    const scale = Math.min(scaleX, scaleY);
+
+    points = points.map(p => ({ x: (p.x) * scale, y: (p.y) * scale }));
+
+    points.forEach(point => {
+        ctx.beginPath();
+        ctx.arc(point.x, point.y, 2, 0, 2 * Math.PI);
+        ctx.fill();
+    });
+
+    const formattedPoints = points.map(p => `{${p.x.toFixed(2)}, ${p.y.toFixed(2)}}`).join(',\n');
+
+    // Calculate polar coordinates
+    const center = findMaximalCenter(points);
+
+    points = points.map(p => ({ x: p.x - center.centerX, y: p.y - center.centerY }));
+    
+    // Calculate initial angles for all points
+    let polarPoints = points.map(p => {
+        const r = Math.sqrt(p.x * p.x + p.y * p.y);
+        // Get the basic angle in radians
+        let theta = Math.atan2(p.y, p.x);
+        
+        // Adjust theta to align 0 degrees to the right and 90 degrees up by flipping the y-axis
+        theta = -theta;
+        
+        return { 
+            r: r * (1000 / Math.max(...points.map(p => Math.sqrt(p.x * p.x + p.y * p.y)))), 
+            theta: theta, // Store in radians initially
+            x: p.x,
+            y: p.y
+        };
+    });
+    
+    // Process points to create continuous theta values
+    for (let i = 1; i < polarPoints.length; i++) {
+        const prev = polarPoints[i-1];
+        const curr = polarPoints[i];
+        
+        // Calculate the difference between current and previous theta
+        let diff = curr.theta - prev.theta;
+        
+        // If the difference is greater than π, it means we've wrapped around counterclockwise
+        // Adjust by subtracting 2π
+        if (diff > Math.PI) {
+            curr.theta -= 2 * Math.PI;
+        }
+        // If the difference is less than -π, it means we've wrapped around clockwise
+        // Adjust by adding 2π
+        else if (diff < -Math.PI) {
+            curr.theta += 2 * Math.PI;
+        }
+    }
+    
+    // Convert to degrees * 10 for the final format
+    polarPoints = polarPoints.map(p => ({
+        r: p.r,
+        theta: p.theta * (1800 / Math.PI) // Convert radians to tenths of degrees
+    }));
+
+    return polarPoints;
+}
+
+
+function plotContours(orderedContours) {
+    const canvas = document.getElementById('plotcontours');
+    const ctx = canvas.getContext('2d');
+
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+    orderedContours.forEach((contour, index) => {
+        const baseColor = getRandomColor();
+        const [r, g, b] = hexToRgb(baseColor);
+        const length = contour.length;
+
+        contour.forEach((point, i) => {
+            if (i === 0) {
+                ctx.beginPath();
+                ctx.moveTo(point.x, point.y);
+            } else {
+                ctx.lineTo(point.x, point.y);
+
+                // Calculate color fade
+                const ratio = i / length;
+                const fadedColor = `rgb(${Math.round(r * (1 - ratio))}, ${Math.round(g * (1 - ratio))}, ${Math.round(b * (1 - ratio))})`;
+                ctx.strokeStyle = fadedColor;
+                ctx.lineWidth = 2;
+                ctx.stroke();
+                ctx.beginPath();
+                ctx.moveTo(point.x, point.y);
+            }
+        });
+
+        // Mark the start and end points
+        ctx.fillStyle = baseColor;
+        ctx.font = '12px Arial';
+
+        // Start point
+        ctx.fillText(`S${index + 1}`, contour[0].x, contour[0].y);
+        ctx.beginPath();
+        ctx.arc(contour[0].x, contour[0].y, 3, 0, 2 * Math.PI);
+        ctx.fill();
+
+        // End point
+        ctx.fillText(`E${index + 1}`, contour[contour.length - 1].x, contour[contour.length - 1].y);
+        ctx.beginPath();
+        ctx.arc(contour[contour.length - 1].x, contour[contour.length - 1].y, 3, 0, 2 * Math.PI);
+        ctx.fill();
+
+        // Label the contour with its number
+        const midPoint = contour[Math.floor(contour.length / 2)];
+        ctx.fillText(`${index + 1}`, midPoint.x, midPoint.y);
+    });
+}
+
+
+function hexToRgb(hex) {
+    const bigint = parseInt(hex.slice(1), 16);
+    return [
+        (bigint >> 16) & 255,
+        (bigint >> 8) & 255,
+        bigint & 255
+    ];
+}
+
+
+function drawConnections(polarPoints) {
+    const canvas = document.getElementById('connect-image'), ctx = canvas.getContext('2d');
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+    const width = canvas.width, height = canvas.height;
+
+    // Reset transformation matrix
+    ctx.setTransform(1, 0, 0, 1, 0, 0);
+
+    // Translate the context to the center of the canvas
+    ctx.translate(width / 2, height / 2);
+
+    // Scale the points based on the size of the original image
+    const scaleX = width / 2000; // Since the circle radius is 1000
+    const scaleY = height / 2000;
+    const scale = Math.min(scaleX, scaleY);
+
+    // Draw the outline circle
+    ctx.beginPath();
+    ctx.arc(0, 0, 1000 * scale, 0, 2 * Math.PI);
+    ctx.strokeStyle = 'black';
+    ctx.stroke();
+
+    // Draw the connections based on polar coordinates
+    for (let i = 0; i < polarPoints.length - 1; i++) {
+        // Calculate the color for each segment
+        const t = i / (polarPoints.length - 1);
+        const color = `hsl(${t * 270}, 100%, 50%)`; // 270 degrees covers red to violet
+        ctx.strokeStyle = color;
+
+        const p1 = polarPoints[i];
+        const p2 = polarPoints[i + 1];
+
+        // Convert from tenths of degrees to radians for visualization
+        const theta1 = p1.theta * Math.PI / 1800;
+        const theta2 = p2.theta * Math.PI / 1800;
+
+        // Adjust y-coordinate calculation to invert the y-axis
+        const x1 = p1.r * Math.cos(theta1) * scale;
+        const y1 = -p1.r * Math.sin(theta1) * scale;
+        const x2 = p2.r * Math.cos(theta2) * scale;
+        const y2 = -p2.r * Math.sin(theta2) * scale;
+
+        ctx.beginPath();
+        ctx.moveTo(x1, y1);
+        ctx.lineTo(x2, y2);
+        ctx.stroke();
+    }
+}
+
+
+function convertImage() {
+    originalImageElement && processImage(originalImageElement);
+}
+
+
+// Function to get URL parameters
+function getUrlParams() {
+    const params = new URLSearchParams(window.location.search);
+    return {
+        apikey: params.get('apikey'),
+        prompt: params.get('prompt'),
+        run: params.get('run')
+    };
+}
+
+
+// Function to fill inputs from URL parameters
+function fillInputsFromParams(params) {
+    if (params.apikey) {
+        document.getElementById('api-key').value = params.apikey;
+    }
+    if (params.prompt) {
+        document.getElementById('prompt').value = params.prompt;
+    }
+}
+
+
+function setDefaultsForAutoGenerate() {
+    document.getElementById('epsilon-slider').value = 0.5;
+    document.getElementById('dot-number').value = 300;
+    document.getElementById('no-shortcuts').checked = true;
+    document.getElementById('is-loop').checked = true;
+    document.getElementById('contour-mode').value = 'Tree';
+    hiddenResponse();
+}
+
+function hiddenResponse() {
+    document.getElementById('master-container').style = 'display: none;';
+    document.getElementById('simple-container').style = 'visibility: visible';
+}
+
+
+document.addEventListener('DOMContentLoaded', function() {
+    const fileInput = document.getElementById('file-input');
+    const fileButton = document.getElementById('file-button');
+    const fileNameDisplay = document.getElementById('file-name');
+    const generateButton = document.getElementById('generate-button');
+    const epsilonSlider = document.getElementById('epsilon-slider');
+    const epsilonValueDisplay = document.getElementById('epsilon-value-display');
+    const dotNumberInput = document.getElementById('dot-number');
+    const contourModeSelect = document.getElementById('contour-mode');
+    //const gaussianBlurToggle = document.getElementById('gaussian-blur-toggle');
+
+    document.getElementById('plotButton').addEventListener('click', plotNextContour);
+
+    generateButton.addEventListener('click', convertImage);
+
+    fileButton.addEventListener('click', function() {
+        fileInput.click();
+    });
+
+    fileInput.addEventListener('change', function(event) {
+        const file = event.target.files[0];
+        if (file) {
+            fileNameDisplay.textContent = file.name;
+            handleImageUpload(event);
+        }
+    });
+
+    epsilonSlider.addEventListener('input', function() {
+        epsilonValueDisplay.textContent = epsilonSlider.value;
+    });
+
+    window.showTab = function(tabName) {
+        const tabContents = document.querySelectorAll('.tab-content');
+        tabContents.forEach(content => { content.style.display = 'none'; });
+        document.getElementById(tabName).style.display = 'block'; 
+    };
+
+});
+
+
+// Initialize the page with URL parameters if present
+document.addEventListener('DOMContentLoaded', (event) => {
+    const { apikey, prompt, run } = getUrlParams();
+
+    // Fill inputs with URL parameters if they exist
+    fillInputsFromParams({ apikey, prompt });
+    if (apikey) {  
+        document.getElementById('api-key-group').style.display = 'none'; 
+    }
+
+    // Generate image if all parameters are present
+    if (apikey && prompt && run) {
+        setDefaultsForAutoGenerate();
+        generateImage(apikey, prompt, run);
+        convertImage();
+    }
+
+    // Add event listener to the button inside the DOMContentLoaded event
+    document.getElementById('gen-image-button').addEventListener('click', () => {
+        let apiKey = document.getElementById('api-key').value;
+        const prompt = document.getElementById('prompt').value + (document.getElementById('googly-eyes').checked ? ' with disproportionately large googly eyes' : '');
+        generateImage(apiKey, prompt, false);
+    });
+    
+});

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 29 - 0
static/js/opencv.js


+ 142 - 4
templates/index.html

@@ -7,6 +7,7 @@
     <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
     <link rel="stylesheet" href="../static/css/style.css">
     <link rel="stylesheet" href="../static/css/all.min.css">
+    <link rel="stylesheet" href="../static/css/image2sand.css">
     <link rel="apple-touch-icon" sizes="180x180" href="../static/apple-touch-icon.png">
     <link rel="icon" type="image/png" sizes="32x32" href="../static/favicon-32x32.png">
     <link rel="icon" type="image/png" sizes="16x16" href="../static/favicon-16x16.png">
@@ -25,11 +26,14 @@
                 <button class="add-button cta" onclick="toggleSecondaryButtons('add-pattern-container')">
                     <i class="fa-solid fa-plus"></i>
                 </button>
+                <button class="gen-button cta" onclick="toggleSecondaryButtons('generate')">
+                    <i class="fa-solid fa-robot"></i>
+                </button>
             </div>
             <div id="add-pattern-container" class="add-to-container hidden">
                 <div class="action-buttons">
-                    <label for="upload_file">Upload pattern file (.thr):</label>
-                    <input type="file" id="upload_file">
+                    <label for="upload_file">Upload pattern file (.thr) or image (.jpg, .png, etc.):</label>
+                    <input type="file" id="upload_file" accept=".thr,.jpg,.jpeg,.png,.gif,.bmp,.webp,.svg">
                     <button class="cta" onclick="uploadThetaRho()">
                         <i class="fa-solid fa-file-arrow-up"></i>
                         <span>Upload</span>
@@ -40,6 +44,27 @@
                     </button>
                 </div>
             </div>
+            <div id="generate" class="add-to-container hidden">
+                <div class="action-buttons">
+                    <div id="api-key-group"></div>
+                    <input type="text" id="api-key" placeholder="Enter a valid OpenAI API key">
+                    <input type="text" id="prompt" placeholder="What do you want to draw?" maxlength="500">
+                    <div class="checkbox-container">
+                        <input type="checkbox" id="googly-eyes" name="googly-eyes">
+                        <label for="googly-eyes">Add googly eyes</label>                        
+                    </div>                    
+                    <div class="action-buttons">
+                        <button id="gen-image-button" class="cta">
+                            <i class="fa-solid fa-wand-sparkles"></i>
+                            <span>Create</span>
+                        </button>
+                        <button class="cancel" onclick="toggleSecondaryButtons('generate')">
+                            <i class="fa-solid fa-xmark"></i>
+                            <span>Cancel</span>
+                        </button>
+                    </div>
+                </div>
+            </div>
             <input type="text" id="search_pattern" placeholder="Search files..." oninput="searchPatternFiles()">
             <ul id="theta_rho_files" class="file-list"></ul>
         </section>
@@ -420,10 +445,123 @@
     <!-- Messages will be appended here -->
 </div>
 
-<div id="debug-log" class="debug-log hidden">
-    <!-- Messages will be appended here -->
+<div id="image-converter-overlay" class="image-converter-overlay">
+    <div class="image-converter-dialog">
+        <div class="image-converter-header">
+            <h2>Convert Image to Pattern</h2>
+            <button class="image-converter-close" onclick="closeImageConverter()">
+                <i class="fa-solid fa-xmark"></i>
+            </button>
+        </div>
+        <div class="image-converter-grid grid">
+            <div class="image-converter-preview">
+                <h3>Original Image</h3>
+                <canvas id="original-image"></canvas>
+            </div>
+            <div class="image-converter-preview">
+                <h3>Detected Edges</h3>
+                <canvas id="edge-image"></canvas>
+            </div>
+            <div class="image-converter-preview">
+                <h3>Identified Points</h3>
+                <canvas id="dot-image"></canvas>
+            </div>
+        </div>
+        
+        <div class="preview-controls-container">
+            <div class="sand-garden-preview">
+                <h3>Sand Garden Preview</h3>
+                <canvas id="connect-image"></canvas>
+                <div id="total-points"></div>
+                <div style="display: none;">
+                    <h4>contours</h4>
+                    <canvas id="plotcontours" width="400" height="400" style="border:1px solid #000000;"></canvas>
+                    <button id="plotButton">Plot Next Contour</button>
+                </div>
+            </div>
+            
+            <div class="image-converter-settings">
+                <div class="setting-item">
+                    <label for="epsilon-slider">Detail Level</label>
+                    <div class="slider-container">
+                        <input type="range" id="epsilon-slider" min="0.1" max="20" step="0.1" value="0.5">
+                        <div class="slider-labels">
+                            <small>Fine</small>
+                            <small id="epsilon-value-display">0.5</small>
+                            <small>Coarse</small>
+                        </div>
+                    </div>
+                </div>
+                <div class="setting-item">
+                    <label for="dot-number">Point Limit</label>
+                    <select id="dot-number">
+                        <option value="100">100 points</option>
+                        <option value="200">200 points</option>
+                        <option value="300">300 points</option>
+                        <option value="400">400 points</option>
+                        <option value="500" selected>500 points</option>
+                        <option value="1000">1000 points</option>
+                    </select>
+                </div>
+                <div class="setting-item">
+                    <label for="contour-mode">Contour Mode</label>
+                    <select id="contour-mode">
+                        <option value="External">External Only</option>
+                        <option value="Tree" selected>External + Internal</option>
+                    </select>
+                </div>
+                <div class="checkbox-group">
+                    <div class="checkbox-container">
+                        <input type="checkbox" id="is-loop" checked>
+                        <label for="is-loop">Loop Drawing</label>
+                    </div>
+                    <div class="checkbox-container">
+                        <input type="checkbox" id="no-shortcuts" checked>
+                        <label for="no-shortcuts">No Shortcuts</label>
+                    </div>
+                </div>
+                <input type="hidden" id="output-type" value="2">
+                <div class="generate-button-container">
+                    <button id="generate-button" class="cta" onclick="convertImage()">
+                        <i class="fa-solid fa-wand-magic-sparkles"></i>
+                        <span>Regenerate Pattern</span>
+                    </button>
+                </div>
+            </div>
+        </div>
+        <div class="image-converter-actions">
+            <button class="cancel" onclick="closeImageConverter()">
+                <i class="fa-solid fa-xmark"></i>
+                <span>Cancel</span>
+            </button>
+            <button class="cta" id="save-pattern-button" onclick="saveConvertedPattern()">
+                <i class="fa-solid fa-floppy-disk"></i>
+                <span>Save Pattern</span>
+            </button>
+        </div>
+        <div id="processing-status" class="processing-indicator">
+            <div class="spinner"></div>
+            <span id="processing-message">Processing image...</span>
+        </div>
+        <div style="display: none;">
+            <!-- hidden inputs for image2sand compatibility. -->
+            <label id="generation-status" style="display: none;">Image is generating - please wait...</label>
+            <textarea id="polar-coordinates-textarea"></textarea>
+            <div id="simple-coords"></div>
+            <div id="simple-coords-title"></div>
+            <input type="checkbox" id="gaussian-blur-toggle">
+            <div>
+                <input type="file" id="file-input">
+                <button id="file-button"></button>
+                <span id="file-name"></span>
+            </div>
+        </div>        
+    </div>
 </div>
 
 <script src="../static/js/main.js"></script>
+<script src="../static/js/image2sand.js"></script>
+<script src="../static/js/image2sand-init.js"></script>
+<script async src="../static/js/opencv.js" onload="console.log('OpenCV.js loaded successfully')"></script>
 </body>
 </html>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů