Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ concurrency:
cancel-in-progress: false

jobs:
# This job builds and deploys the site to GitHub Pages.
deploy:
environment:
name: github-pages
# The URL for the deployed site will be available as an output of the deployment step.
# For the `dev` branch, this will be a unique preview URL for that specific deployment.
# You can find this URL in the summary of the workflow run in the "Actions" tab.
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
Expand All @@ -46,8 +50,11 @@ jobs:
ref: ${{ github.event.inputs.branch || github.ref_name }}
- name: Setup Pages
uses: actions/configure-pages@v4
with:
# Automatically enables Pages for the repository
enablement: true
- name: Upload artifact
uses: actions/upload-pages-artifact@v2
uses: actions/upload-pages-artifact@v4
with:
# Upload entire repository
path: '.'
Expand Down
16 changes: 15 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ <h2>Controls</h2>
</div>
<div class="control-group">
<label for="threads-input">Number of Threads:</label>
<input type="number" id="threads-input" value="1000" min="100" max="10000">
<input type="number" id="threads-input" value="3000" min="100" max="10000">
</div>
<button id="generate-btn">Generate</button>
</div>
Expand All @@ -41,6 +41,10 @@ <h2>Controls</h2>
<h3>Original Image</h3>
<canvas id="original-canvas"></canvas>
</div>
<div class="image-display">
<h3>Processed Image</h3>
<canvas id="processed-canvas"></canvas>
</div>
<div class="image-display">
<h3>String Art</h3>
<canvas id="string-art-canvas"></canvas>
Expand All @@ -51,6 +55,16 @@ <h3>String Art</h3>
<h2>Instructions</h2>
<ol id="instructions-list"></ol>
</div>

<div id="progress-overlay" class="hidden">
<div class="progress-container">
<p>Generating String Art...</p>
<div class="progress-bar-container">
<div id="progress-bar" class="progress-bar"></div>
</div>
<p id="progress-text">0%</p>
</div>
</div>
</main>

<script src="script.js"></script>
Expand Down
62 changes: 62 additions & 0 deletions jules-scratch/verification/verify_intelligent_stop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import os
from playwright.sync_api import sync_playwright, expect

def run_verification():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()

base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
file_path = os.path.join(base_dir, 'index.html')

page.goto(f'file://{file_path}')
page.wait_for_load_state('domcontentloaded')

# --- Test the "Intelligent Stop" feature ---
page.evaluate("""
// Manually set canvas size, which normally happens on image load
const canvas = document.getElementById('string-art-canvas');
canvas.width = 400;
canvas.height = 400;

// Mock the processedImageData with a completely white image
const size = 200;
const mockData = new Uint8ClampedArray(size * size * 4);
// Inverted grayscale: 0 = white for the algorithm, so all scores will be 0
mockData.fill(0);
window.processedImageData = new ImageData(mockData, size, size);
""")

# Set a high thread count that should not be reached
page.locator("#threads-input").fill("5000")

# Listen for the console message that indicates an early stop
message_found = False
def handle_console(msg):
nonlocal message_found
if "Stopping early" in msg.text:
message_found = True

page.on("console", handle_console)

# Click the generate button
page.get_by_role("button", name="Generate").click()

# Wait for the progress bar to disappear, indicating completion
expect(page.locator("#progress-overlay")).to_be_hidden(timeout=10000)

# Assert that the early stop message was logged
assert message_found, "The 'Intelligent Stop' feature did not trigger as expected."

# Assert that no instructions were generated because the image was blank
expect(page.locator("#instructions-list li")).to_have_count(0)

# Take a screenshot for final verification
screenshot_path = "jules-scratch/verification/intelligent_stop_test.png"
page.screenshot(path=screenshot_path)

browser.close()
print(f"Screenshot saved to {screenshot_path}")

if __name__ == "__main__":
run_verification()
140 changes: 106 additions & 34 deletions script.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
let processedImageData = null;

document.addEventListener('DOMContentLoaded', () => {
const imageUpload = document.getElementById('image-upload');
const shapeSelect = document.getElementById('shape-select');
Expand All @@ -6,12 +8,16 @@ document.addEventListener('DOMContentLoaded', () => {
const generateBtn = document.getElementById('generate-btn');
const originalCanvas = document.getElementById('original-canvas');
const stringArtCanvas = document.getElementById('string-art-canvas');
const processedCanvas = document.getElementById('processed-canvas');
const instructionsList = document.getElementById('instructions-list');
const progressOverlay = document.getElementById('progress-overlay');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');

const originalCtx = originalCanvas.getContext('2d');
const stringArtCtx = stringArtCanvas.getContext('2d');
const processedCtx = processedCanvas.getContext('2d');

let processedImageData = null;
let image = new Image();

imageUpload.addEventListener('change', (e) => {
Expand Down Expand Up @@ -56,14 +62,31 @@ document.addEventListener('DOMContentLoaded', () => {
const imageData = tempCtx.getImageData(0, 0, size, size);
const data = imageData.data;

// Create a separate grayscale image for display
const displayImageData = new ImageData(size, size);
const displayData = displayImageData.data;

for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
const grayscale = 255 - avg; // Invert so darkness is higher value
data[i] = grayscale;
data[i + 1] = grayscale;
data[i + 2] = grayscale;
// For the algorithm, we want inverted (darkness = high value)
const invertedGrayscale = 255 - avg;
data[i] = invertedGrayscale;
data[i + 1] = invertedGrayscale;
data[i + 2] = invertedGrayscale;

// For display, we want normal grayscale
displayData[i] = avg;
displayData[i + 1] = avg;
displayData[i + 2] = avg;
displayData[i + 3] = 255; // Alpha
}
processedImageData = imageData;

// Display the processed image
processedCanvas.width = size;
processedCanvas.height = size;
processedCtx.putImageData(displayImageData, 0, 0);

console.log("Image processed");
}

Expand Down Expand Up @@ -95,12 +118,26 @@ document.addEventListener('DOMContentLoaded', () => {
}


function generateStringArt() {
// Helper to run tasks asynchronously in chunks to avoid freezing the UI
function runAsyncTask(task) {
return new Promise(resolve => {
setTimeout(() => {
resolve(task());
}, 0);
});
}


async function generateStringArt() {
if (!processedImageData) {
alert('Please upload an image first.');
return;
}

// --- Show progress bar and disable button ---
generateBtn.disabled = true;
progressOverlay.classList.remove('hidden');

const numPins = parseInt(pinsInput.value);
const numThreads = parseInt(threadsInput.value);
const shape = shapeSelect.value;
Expand All @@ -112,71 +149,104 @@ document.addEventListener('DOMContentLoaded', () => {

const pins = getPinCoordinates(numPins, shape, w, h);

// Draw pins
stringArtCtx.fillStyle = 'black';
pins.forEach(pin => {
stringArtCtx.beginPath();
stringArtCtx.arc(pin.x, pin.y, 2, 0, 2 * Math.PI);
stringArtCtx.fill();
});


// Create a copy of the image data for the algorithm to modify
const imgDataCopy = new Uint8ClampedArray(processedImageData.data);
const imgWidth = processedImageData.width;

let currentPinIndex = 0;
const path = [0];
const instructions = [];
const lineMemory = new Set(); // To prevent drawing the same line twice

stringArtCtx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
stringArtCtx.lineWidth = 0.5;


for (let i = 0; i < numThreads; i++) {
let bestNextPin = -1;
let maxDarkness = -Infinity;

for (let nextPinIndex = 0; nextPinIndex < numPins; nextPinIndex++) {
// Don't connect to self or immediate neighbors for better patterns
if (nextPinIndex === currentPinIndex || Math.abs(nextPinIndex - currentPinIndex) < 5) {
continue;
let maxDarkness = 0; // Initialize to 0, not -Infinity

// We wrap the inner loop in an async task to allow UI updates
await runAsyncTask(() => {
for (let nextPinIndex = 0; nextPinIndex < numPins; nextPinIndex++) {
// Don't connect to self
if (nextPinIndex === currentPinIndex) continue;

// Create a unique key for the pin pair, order doesn't matter
const lineKey = `${Math.min(currentPinIndex, nextPinIndex)}-${Math.max(currentPinIndex, nextPinIndex)}`;
if (lineMemory.has(lineKey)) {
continue; // Skip if we've already drawn this line
}

const linePixels = getLinePixels(pins[currentPinIndex], pins[nextPinIndex], w, h, imgWidth, imgWidth);
let currentDarkness = 0;
for (const pixel of linePixels) {
const index = (pixel.y * imgWidth + pixel.x) * 4;
currentDarkness += imgDataCopy[index];
}

// --- Improved Scoring ---
// Favor longer lines to avoid getting stuck
const dx = pins[currentPinIndex].x - pins[nextPinIndex].x;
const dy = pins[currentPinIndex].y - pins[nextPinIndex].y;
const length = Math.sqrt(dx * dx + dy * dy);

// The score is a combination of darkness and length
// The exponent on length gives it more weight, preventing short, repetitive lines.
const score = currentDarkness * Math.pow(length, 0.5);

if (score > maxDarkness) {
maxDarkness = score;
bestNextPin = nextPinIndex;
}
}
});

const linePixels = getLinePixels(pins[currentPinIndex], pins[nextPinIndex], w, h, imgWidth, imgWidth);
let currentDarkness = 0;
for (const pixel of linePixels) {
const index = (pixel.y * imgWidth + pixel.x) * 4;
currentDarkness += imgDataCopy[index];
}

if (currentDarkness > maxDarkness) {
maxDarkness = currentDarkness;
bestNextPin = nextPinIndex;
}
// --- Intelligent Stop ---
// If the best score is very low, it means there are no more good lines to draw.
// A threshold of 1 is arbitrary but works as a floor to prevent drawing "nothing" lines.
if (bestNextPin === -1 || maxDarkness < 1) {
console.log(`Stopping early at thread ${i} because no good lines were found.`);
break; // Exit the loop
}

if (bestNextPin !== -1) {
// Draw the line on the canvas
stringArtCtx.beginPath();
stringArtCtx.moveTo(pins[currentPinIndex].x, pins[currentPinIndex].y);
stringArtCtx.lineTo(pins[bestNextPin].x, pins[bestNextPin].y);
stringArtCtx.stroke();

// "Remove" the darkness from the image copy
const bestLinePixels = getLinePixels(pins[currentPinIndex], pins[bestNextPin], w, h, imgWidth, imgWidth);
for (const pixel of bestLinePixels) {
const index = (pixel.y * imgWidth + pixel.x) * 4;
imgDataCopy[index] = 0;
imgDataCopy[index + 1] = 0;
imgDataCopy[index + 2] = 0;
// "Bleach" the line by reducing darkness
imgDataCopy[index] = Math.max(0, imgDataCopy[index] - 64);
imgDataCopy[index + 1] = Math.max(0, imgDataCopy[index + 1] - 64);
imgDataCopy[index + 2] = Math.max(0, imgDataCopy[index + 2] - 64);
}

instructions.push({ from: currentPinIndex + 1, to: bestNextPin + 1 });
const lineKey = `${Math.min(currentPinIndex, bestNextPin)}-${Math.max(currentPinIndex, bestNextPin)}`;
lineMemory.add(lineKey); // Remember this line
currentPinIndex = bestNextPin;
path.push(currentPinIndex);

}

// --- Update progress bar ---
const progress = ((i + 1) / numThreads) * 100;
progressBar.style.width = `${progress}%`;
progressText.textContent = `${Math.round(progress)}%`;
}

// --- Hide progress bar and re-enable button ---
generateBtn.disabled = false;
progressOverlay.classList.add('hidden');

displayInstructions(instructions);
}

Expand Down Expand Up @@ -220,5 +290,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
}

generateBtn.addEventListener('click', generateStringArt);
generateBtn.addEventListener('click', async () => {
await generateStringArt();
});
});
Loading