/* * 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}. Make the line black and the background white. The drawing should be a single line, don't add any additional details to the image.`; 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 const genImageButton = document.getElementById('gen-image-button'); if (genImageButton) { genImageButton.addEventListener('click', () => { const apiKeyElement = document.getElementById('api-key'); const promptElement = document.getElementById('prompt'); const googlyEyes = document.getElementById('googly-eyes'); // Add null checks const apiKey = apiKeyElement?.value || ''; const promptValue = promptElement?.value || ''; const googlyEyesChecked = googlyEyes?.checked || false; const prompt = promptValue + (googlyEyesChecked ? ' with disproportionately large googly eyes' : ''); generateImage(apiKey, prompt, false); }); } });