diff options
| author | Rishi-k-s <rishikrishna.sr@gmail.com> | 2025-07-25 12:17:54 +0530 |
|---|---|---|
| committer | Rishi-k-s <rishikrishna.sr@gmail.com> | 2025-07-25 12:17:54 +0530 |
| commit | 9c1a4b4b9f76ba38109e4528daa1f5fa324f830b (patch) | |
| tree | 62f170a3a037d24fb48161620a225ea819b87930 | |
first commit
| -rw-r--r-- | .vscode/settings.json | 3 | ||||
| -rw-r--r-- | README.md | 81 | ||||
| -rw-r--r-- | index.html | 95 | ||||
| -rw-r--r-- | script.js | 420 | ||||
| -rw-r--r-- | style.css | 287 |
5 files changed, 886 insertions, 0 deletions
diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f3a291 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +}
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b53a01 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Image to Halftone Converter + +A web-based tool that converts images into beautiful halftone patterns using vanilla JavaScript and HTML5 Canvas. This project is inspired by traditional halftone printing techniques used in newspapers and comic books. + +## Features + +- **Image Upload**: Drag and drop or click to upload images +- **Real-time Controls**: Adjust halftone parameters with interactive sliders +- **Multiple Effect Types**: + - Basic Halftone + - Duotone (two-color effect) + - Tritone (three-color effect) +- **Customizable Parameters**: + - Dot size (1-20 pixels) + - Dot spacing (1-15 pixels) + - Screen angle (0-89 degrees) + - Dot color + - Background color +- **Download Results**: Save your halftone images as PNG files +- **Responsive Design**: Works on desktop and mobile devices + +## How to Use + +1. **Upload an Image**: Click on the upload area or drag and drop an image file +2. **Adjust Settings**: Use the control panel to customize: + - **Dot Size**: Controls how large the halftone dots can get + - **Dot Spacing**: Controls the distance between dot centers + - **Screen Angle**: Rotates the halftone pattern + - **Colors**: Choose dot and background colors + - **Effect Type**: Select from basic, duotone, or tritone effects +3. **Generate**: Click "Generate Halftone" to create your effect +4. **Download**: Save the result to your device + +## Technical Details + +This implementation is based on the halftone printing process where: +- Images are converted to grayscale +- Dot sizes vary based on the brightness of the original image +- Darker areas create larger dots, lighter areas create smaller dots +- Multiple angles can be used to create complex color effects + +The halftone algorithm: +1. Samples the source image at regular intervals +2. Converts pixel values to grayscale using luminance formula (0.299*R + 0.587*G + 0.114*B) +3. Maps brightness values to dot sizes +4. Draws circles using HTML5 Canvas arc method +5. Supports rotation for different screen angles + +## File Structure + +``` +├── index.html # Main HTML structure +├── style.css # Styling and responsive design +├── script.js # Halftone conversion logic +└── README.md # This documentation +``` + +## Browser Compatibility + +- Modern browsers supporting HTML5 Canvas +- File API for image upload +- ES6+ JavaScript features + +## Inspiration + +This project is inspired by: +- Traditional halftone printing techniques +- [anderoonies.github.io/projects/halftone/](https://anderoonies.github.io/projects/halftone/) +- Comic book and newspaper printing methods + +## Future Enhancements + +- CMYK color separation +- Batch processing +- More effect presets +- Print-ready output formats +- Advanced moiré pattern control + +## License + +This project is open source and available under the MIT License. diff --git a/index.html b/index.html new file mode 100644 index 0000000..dcfa4ca --- /dev/null +++ b/index.html @@ -0,0 +1,95 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Image to Halftone Converter</title> + <link rel="stylesheet" href="style.css"> +</head> +<body> + <div class="container"> + <header> + <h1>Image to Halftone Converter</h1> + <p>Convert your images into beautiful halftone patterns</p> + </header> + + <main> + <section class="upload-section"> + <div class="upload-area" id="uploadArea"> + <div class="upload-content"> + <div class="upload-icon">📷</div> + <p>Click here or drag and drop an image</p> + <input type="file" id="imageInput" accept="image/*" style="display: none;"> + </div> + </div> + </section> + + <section class="controls-section" id="controlsSection" style="display: none;"> + <div class="controls-grid"> + <div class="control-group"> + <label for="dotSize">Dot Size:</label> + <input type="range" id="dotSize" min="1" max="20" value="5"> + <span id="dotSizeValue">5</span>px + </div> + + <div class="control-group"> + <label for="dotResolution">Dot Spacing:</label> + <input type="range" id="dotResolution" min="1" max="15" value="4"> + <span id="dotResolutionValue">4</span>px + </div> + + <div class="control-group"> + <label for="angle">Screen Angle:</label> + <input type="range" id="angle" min="0" max="89" value="0"> + <span id="angleValue">0</span>° + </div> + + <div class="control-group"> + <label for="halftoneColor">Dot Color:</label> + <input type="color" id="halftoneColor" value="#000000"> + </div> + + <div class="control-group"> + <label for="backgroundColor">Background:</label> + <input type="color" id="backgroundColor" value="#ffffff"> + </div> + + <div class="control-group"> + <label for="effectType">Effect Type:</label> + <select id="effectType"> + <option value="basic">Basic Halftone</option> + <option value="duotone">Duotone</option> + <option value="tritone">Tritone</option> + </select> + </div> + </div> + + <div class="button-group"> + <button id="generateBtn">Generate Halftone</button> + <button id="downloadBtn" style="display: none;">Download Result</button> + <button id="resetBtn">Reset</button> + </div> + </section> + + <section class="preview-section"> + <div class="canvas-container"> + <div class="canvas-wrapper"> + <h3>Original</h3> + <canvas id="sourceCanvas"></canvas> + </div> + <div class="canvas-wrapper"> + <h3>Halftone Result</h3> + <canvas id="targetCanvas"></canvas> + </div> + </div> + </section> + </main> + + <footer> + <p>Inspired by traditional halftone printing techniques</p> + </footer> + </div> + + <script src="script.js"></script> +</body> +</html> diff --git a/script.js b/script.js new file mode 100644 index 0000000..eca6ff8 --- /dev/null +++ b/script.js @@ -0,0 +1,420 @@ +class HalftoneConverter { + constructor() { + this.sourceCanvas = document.getElementById('sourceCanvas'); + this.targetCanvas = document.getElementById('targetCanvas'); + this.sourceCtx = this.sourceCanvas.getContext('2d'); + this.targetCtx = this.targetCanvas.getContext('2d'); + this.currentImage = null; + + // Enable anti-aliasing and smooth rendering + this.sourceCtx.imageSmoothingEnabled = true; + this.sourceCtx.imageSmoothingQuality = 'high'; + this.targetCtx.imageSmoothingEnabled = true; + this.targetCtx.imageSmoothingQuality = 'high'; + + this.initializeEventListeners(); + this.initializeControls(); + } + + initializeEventListeners() { + const uploadArea = document.getElementById('uploadArea'); + const imageInput = document.getElementById('imageInput'); + const generateBtn = document.getElementById('generateBtn'); + const downloadBtn = document.getElementById('downloadBtn'); + const resetBtn = document.getElementById('resetBtn'); + + // File upload handling + uploadArea.addEventListener('click', () => imageInput.click()); + uploadArea.addEventListener('dragover', this.handleDragOver.bind(this)); + uploadArea.addEventListener('dragleave', this.handleDragLeave.bind(this)); + uploadArea.addEventListener('drop', this.handleDrop.bind(this)); + imageInput.addEventListener('change', this.handleFileSelect.bind(this)); + + // Button handlers + generateBtn.addEventListener('click', this.generateHalftone.bind(this)); + downloadBtn.addEventListener('click', this.downloadResult.bind(this)); + resetBtn.addEventListener('click', this.reset.bind(this)); + + // Real-time control updates + const controls = ['dotSize', 'dotResolution', 'angle', 'halftoneColor', 'backgroundColor', 'effectType']; + controls.forEach(id => { + const element = document.getElementById(id); + element.addEventListener('input', this.updateControlValues.bind(this)); + }); + } + + initializeControls() { + this.updateControlValues(); + } + + updateControlValues() { + const dotSize = document.getElementById('dotSize'); + const dotResolution = document.getElementById('dotResolution'); + const angle = document.getElementById('angle'); + + document.getElementById('dotSizeValue').textContent = dotSize.value; + document.getElementById('dotResolutionValue').textContent = dotResolution.value; + document.getElementById('angleValue').textContent = angle.value; + } + + handleDragOver(e) { + e.preventDefault(); + document.getElementById('uploadArea').classList.add('dragover'); + } + + handleDragLeave(e) { + e.preventDefault(); + document.getElementById('uploadArea').classList.remove('dragover'); + } + + handleDrop(e) { + e.preventDefault(); + document.getElementById('uploadArea').classList.remove('dragover'); + const files = e.dataTransfer.files; + if (files.length > 0) { + this.loadImage(files[0]); + } + } + + handleFileSelect(e) { + const file = e.target.files[0]; + if (file) { + this.loadImage(file); + } + } + + loadImage(file) { + if (!file.type.startsWith('image/')) { + alert('Please select a valid image file.'); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + this.currentImage = img; + this.displayOriginalImage(); + document.getElementById('controlsSection').style.display = 'block'; + document.getElementById('controlsSection').classList.add('fade-in'); + }; + img.src = e.target.result; + }; + reader.readAsDataURL(file); + } + + displayOriginalImage() { + const maxWidth = 400; + const maxHeight = 400; + + let { width, height } = this.currentImage; + + // Calculate aspect ratio and resize if needed + if (width > maxWidth || height > maxHeight) { + const aspectRatio = width / height; + if (width > height) { + width = maxWidth; + height = maxWidth / aspectRatio; + } else { + height = maxHeight; + width = maxHeight * aspectRatio; + } + } + + this.sourceCanvas.width = width; + this.sourceCanvas.height = height; + + // Enable high-quality image rendering + this.sourceCtx.imageSmoothingEnabled = true; + this.sourceCtx.imageSmoothingQuality = 'high'; + + this.sourceCtx.drawImage(this.currentImage, 0, 0, width, height); + } + + // Utility functions from the reference + positionToDataIndex(x, y, width) { + return (y * width + x) * 4; + } + + map(value, minA, maxA, minB, maxB) { + return ((value - minA) / (maxA - minA)) * (maxB - minB) + minB; + } + + rotatePointAboutPosition([x, y], [rotX, rotY], angle) { + return [ + (x - rotX) * Math.cos(angle) - (y - rotY) * Math.sin(angle) + rotX, + (x - rotX) * Math.sin(angle) + (y - rotY) * Math.cos(angle) + rotY, + ]; + } + + halftone({ angle, dotSize, dotResolution, targetCtx, sourceCtx, width, height, color, layer = false }) { + const sourceImageData = sourceCtx.getImageData(0, 0, width, height); + angle = (angle * Math.PI) / 180; + + // Enable anti-aliasing for smoother circles + targetCtx.imageSmoothingEnabled = true; + targetCtx.imageSmoothingQuality = 'high'; + + if (!layer) { + targetCtx.fillStyle = document.getElementById('backgroundColor').value; + targetCtx.fillRect(0, 0, width, height); + } + + targetCtx.fillStyle = color; + + // Get the four corners of the screen + const tl = [0, 0]; + const tr = [width, 0]; + const br = [width, height]; + const bl = [0, height]; + + // Rotate the screen, then find the minimum and maximum of the values + const boundaries = [tl, br, tr, bl].map(([x, y]) => { + return this.rotatePointAboutPosition([x, y], [width / 2, height / 2], angle); + }); + + const minX = Math.min(...boundaries.map((point) => point[0])) | 0; + const minY = Math.min(...boundaries.map((point) => point[1])) | 0; + const maxY = Math.max(...boundaries.map((point) => point[1])) | 0; + const maxX = Math.max(...boundaries.map((point) => point[0])) | 0; + + for (let y = minY; y < maxY; y += dotResolution) { + for (let x = minX; x < maxX; x += dotResolution) { + let [rotatedX, rotatedY] = this.rotatePointAboutPosition( + [x, y], + [width / 2, height / 2], + -angle + ); + + if (rotatedX < 0 || rotatedY < 0 || rotatedX > width || rotatedY > height) { + continue; + } + + const index = this.positionToDataIndex( + Math.floor(rotatedX), + Math.floor(rotatedY), + width + ); + + // Convert to grayscale using luminance formula + const r = sourceImageData.data[index]; + const g = sourceImageData.data[index + 1]; + const b = sourceImageData.data[index + 2]; + const alpha = sourceImageData.data[index + 3]; + const value = (r * 0.299 + g * 0.587 + b * 0.114); + + if (alpha > 0) { + const circleRadius = this.map(value, 0, 255, dotSize / 2, 0); + + if (circleRadius > 0.1) { // Only draw circles with meaningful size + targetCtx.beginPath(); + targetCtx.arc(rotatedX, rotatedY, circleRadius, 0, Math.PI * 2); + targetCtx.closePath(); + targetCtx.fill(); + } + } + } + } + } + + generateBasicHalftone() { + const dotSize = parseInt(document.getElementById('dotSize').value); + const dotResolution = parseInt(document.getElementById('dotResolution').value); + const angle = parseInt(document.getElementById('angle').value); + const color = document.getElementById('halftoneColor').value; + + this.halftone({ + angle: angle, + dotSize: dotSize, + dotResolution: dotResolution, + targetCtx: this.targetCtx, + sourceCtx: this.sourceCtx, + width: this.sourceCanvas.width, + height: this.sourceCanvas.height, + color: color + }); + } + + generateDuotone() { + const dotSize = parseInt(document.getElementById('dotSize').value); + const dotResolution = parseInt(document.getElementById('dotResolution').value); + const angle = parseInt(document.getElementById('angle').value); + + // Create two layers - one for highlights, one for shadows + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + tempCanvas.width = this.sourceCanvas.width; + tempCanvas.height = this.sourceCanvas.height; + + // Layer 1: All values (light color) + this.halftone({ + angle: angle + 15, + dotSize: dotSize, + dotResolution: dotResolution, + targetCtx: this.targetCtx, + sourceCtx: this.sourceCtx, + width: this.sourceCanvas.width, + height: this.sourceCanvas.height, + color: '#8B4513' // Brown + }); + + // Create shadow layer + const sourceImageData = this.sourceCtx.getImageData(0, 0, this.sourceCanvas.width, this.sourceCanvas.height); + const shadowImageData = tempCtx.createImageData(this.sourceCanvas.width, this.sourceCanvas.height); + + for (let i = 0; i < sourceImageData.data.length; i += 4) { + const r = sourceImageData.data[i]; + const g = sourceImageData.data[i + 1]; + const b = sourceImageData.data[i + 2]; + const value = (r * 0.299 + g * 0.587 + b * 0.114); + + if (value < 127) { + const adjustedValue = this.map(value, 0, 127, 0, 255); + shadowImageData.data[i] = adjustedValue; + shadowImageData.data[i + 1] = adjustedValue; + shadowImageData.data[i + 2] = adjustedValue; + shadowImageData.data[i + 3] = 255; + } else { + shadowImageData.data[i] = 255; + shadowImageData.data[i + 1] = 255; + shadowImageData.data[i + 2] = 255; + shadowImageData.data[i + 3] = 255; + } + } + + tempCtx.putImageData(shadowImageData, 0, 0); + + // Layer 2: Shadows (black) + this.halftone({ + angle: angle, + dotSize: dotSize, + dotResolution: dotResolution, + targetCtx: this.targetCtx, + sourceCtx: tempCtx, + width: this.sourceCanvas.width, + height: this.sourceCanvas.height, + color: '#000000', + layer: true + }); + } + + generateTritone() { + const dotSize = parseInt(document.getElementById('dotSize').value); + const dotResolution = parseInt(document.getElementById('dotResolution').value); + const angle = parseInt(document.getElementById('angle').value); + + const colors = ['#FFD700', '#FF6347', '#000000']; // Gold, Tomato, Black + const angles = [angle, angle + 30, angle + 60]; + + colors.forEach((color, index) => { + this.halftone({ + angle: angles[index], + dotSize: dotSize, + dotResolution: dotResolution, + targetCtx: this.targetCtx, + sourceCtx: this.sourceCtx, + width: this.sourceCanvas.width, + height: this.sourceCanvas.height, + color: color, + layer: index > 0 + }); + }); + } + + generateHalftone() { + if (!this.currentImage) { + alert('Please upload an image first.'); + return; + } + + // Set up target canvas with high-resolution rendering + const scaleFactor = 2; // Render at 2x resolution for smoother output + const displayWidth = this.sourceCanvas.width; + const displayHeight = this.sourceCanvas.height; + + // Set actual canvas size to higher resolution + this.targetCanvas.width = displayWidth * scaleFactor; + this.targetCanvas.height = displayHeight * scaleFactor; + + // Scale the context to match + this.targetCtx.scale(scaleFactor, scaleFactor); + + // Set display size to normal + this.targetCanvas.style.width = displayWidth + 'px'; + this.targetCanvas.style.height = displayHeight + 'px'; + + const effectType = document.getElementById('effectType').value; + + // Add loading state + document.getElementById('generateBtn').classList.add('loading'); + document.getElementById('generateBtn').textContent = 'Generating...'; + + // Use setTimeout to allow UI to update + setTimeout(() => { + try { + switch (effectType) { + case 'basic': + this.generateBasicHalftone(); + break; + case 'duotone': + this.generateDuotone(); + break; + case 'tritone': + this.generateTritone(); + break; + } + + document.getElementById('downloadBtn').style.display = 'inline-block'; + document.getElementById('downloadBtn').classList.add('fade-in'); + } catch (error) { + console.error('Error generating halftone:', error); + alert('An error occurred while generating the halftone. Please try again.'); + } finally { + document.getElementById('generateBtn').classList.remove('loading'); + document.getElementById('generateBtn').textContent = 'Generate Halftone'; + } + }, 100); + } + + downloadResult() { + if (!this.targetCanvas.width) { + alert('Please generate a halftone first.'); + return; + } + + const link = document.createElement('a'); + link.download = 'halftone-result.png'; + link.href = this.targetCanvas.toDataURL(); + link.click(); + } + + reset() { + this.currentImage = null; + this.sourceCtx.clearRect(0, 0, this.sourceCanvas.width, this.sourceCanvas.height); + this.targetCtx.clearRect(0, 0, this.targetCanvas.width, this.targetCanvas.height); + + // Reset canvas transformations + this.targetCtx.setTransform(1, 0, 0, 1, 0, 0); + this.targetCanvas.style.width = ''; + this.targetCanvas.style.height = ''; + + document.getElementById('controlsSection').style.display = 'none'; + document.getElementById('downloadBtn').style.display = 'none'; + document.getElementById('imageInput').value = ''; + + // Reset controls to default values + document.getElementById('dotSize').value = 5; + document.getElementById('dotResolution').value = 4; + document.getElementById('angle').value = 0; + document.getElementById('halftoneColor').value = '#000000'; + document.getElementById('backgroundColor').value = '#ffffff'; + document.getElementById('effectType').value = 'basic'; + + this.updateControlValues(); + } +} + +// Initialize the application when the DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + new HalftoneConverter(); +}); diff --git a/style.css b/style.css new file mode 100644 index 0000000..f36fd4c --- /dev/null +++ b/style.css @@ -0,0 +1,287 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: #333; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +header { + text-align: center; + margin-bottom: 40px; + color: white; +} + +header h1 { + font-size: 2.5rem; + margin-bottom: 10px; + text-shadow: 2px 2px 4px rgba(0,0,0,0.3); +} + +header p { + font-size: 1.1rem; + opacity: 0.9; +} + +main { + background: white; + border-radius: 15px; + padding: 30px; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); +} + +.upload-section { + margin-bottom: 30px; +} + +.upload-area { + border: 3px dashed #ccc; + border-radius: 10px; + padding: 40px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background: #f9f9f9; +} + +.upload-area:hover { + border-color: #667eea; + background: #f0f4ff; +} + +.upload-area.dragover { + border-color: #667eea; + background: #e8f0fe; +} + +.upload-content { + pointer-events: none; +} + +.upload-icon { + font-size: 3rem; + margin-bottom: 15px; +} + +.upload-area p { + font-size: 1.1rem; + color: #666; +} + +.controls-section { + margin-bottom: 30px; + padding: 25px; + background: #f8f9fa; + border-radius: 10px; +} + +.controls-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 25px; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.control-group label { + font-weight: 600; + color: #444; +} + +.control-group input[type="range"] { + width: 100%; + height: 6px; + background: #ddd; + outline: none; + border-radius: 3px; + -webkit-appearance: none; + appearance: none; +} + +.control-group input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + background: #667eea; + cursor: pointer; + border-radius: 50%; +} + +.control-group input[type="range"]::-moz-range-thumb { + width: 20px; + height: 20px; + background: #667eea; + cursor: pointer; + border-radius: 50%; + border: none; +} + +.control-group input[type="color"] { + width: 60px; + height: 40px; + border: none; + border-radius: 5px; + cursor: pointer; +} + +.control-group select { + padding: 8px 12px; + border: 2px solid #ddd; + border-radius: 5px; + background: white; + font-size: 14px; +} + +.button-group { + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; +} + +button { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +#generateBtn { + background: #28a745; + color: white; +} + +#generateBtn:hover { + background: #218838; + transform: translateY(-2px); +} + +#downloadBtn { + background: #007bff; + color: white; +} + +#downloadBtn:hover { + background: #0056b3; + transform: translateY(-2px); +} + +#resetBtn { + background: #6c757d; + color: white; +} + +#resetBtn:hover { + background: #545b62; + transform: translateY(-2px); +} + +.preview-section { + margin-top: 30px; +} + +.canvas-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; + margin-top: 20px; +} + +.canvas-wrapper { + text-align: center; +} + +.canvas-wrapper h3 { + margin-bottom: 15px; + color: #444; + font-size: 1.2rem; +} + +canvas { + max-width: 100%; + border: 2px solid #ddd; + border-radius: 8px; + box-shadow: 0 4px 15px rgba(0,0,0,0.1); +} + +footer { + text-align: center; + margin-top: 40px; + color: white; + opacity: 0.8; +} + +.loading { + opacity: 0.6; + pointer-events: none; +} + +/* Responsive design */ +@media (max-width: 768px) { + .container { + padding: 15px; + } + + header h1 { + font-size: 2rem; + } + + main { + padding: 20px; + } + + .controls-grid { + grid-template-columns: 1fr; + } + + .canvas-container { + grid-template-columns: 1fr; + gap: 20px; + } + + .button-group { + flex-direction: column; + align-items: center; + } + + button { + width: 200px; + } +} + +/* Animation for smooth transitions */ +.fade-in { + animation: fadeIn 0.5s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} |
