Added img2scad.html web app to scripts directory

This commit is contained in:
Alex Matulich
2025-04-22 23:45:39 -07:00
parent 9f1991730a
commit dbe5e6e10c

406
scripts/img2scad.html Normal file
View File

@@ -0,0 +1,406 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!--
Standalone web app to convert an image file to an OpenSCAD array, for use with BOSL2 textures.
Versions 1-5: 22 April 2025 - by Alex Matulich
-->
<title>Image to OpenSCAD array, v5</title>
<meta charset="UTF-8">
<style>
body { font-family: sans-serif; padding-left:1em; padding-right:1em;}
h1,h2,h3,h4 { font-family: serif; }
.uiContainer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 20px;
margin: 10px 0 10px 0;
}
#inputArea {
background-color: #FFFFBB;
border: 6px outset #DDDD99;
padding: 1em;
}
#outputArea {
background-color: #EEFFEE;
border: 6px outset #BBDDBB;
padding: 1em;
}
.canvasWrapper {
display: flex;
flex-direction: column;
align-items: center;
}
canvas {
border: 1px solid #ccc;
}
.tooltip {
position: relative;
display: inline-block;
border-bottom: 1px dotted black;
}
.tooltip .tooltiptext {
visibility: hidden;
white-space: nowrap;
display: block;
font-size: small;
background-color: black;
color: #fff;
text-align: left;
border-radius: 6px;
padding: 5px;
/* Position the tooltip */
position: absolute;
z-index: 1;
}
.tooltip:hover .tooltiptext {
visibility: visible;
}
</style>
</head>
<body>
<h1>Convert image to OpenSCAD array</h1>
<p>This utility accepts any raster image and converts it to grayscale expanded to use the maximum possible luminance range. Alpha channel is ignored. After resizing, rotating, or reflecting the image as desired, you may save it as an OpenSCAD array.</p>
<hr>
<div id="content">
<div class="uiContainer" id="inputArea" tabindex="0">
<div>
<h3>Select an image</h2>
<input type="file" id="imageInput" accept="image/*">
<p><em>You can also paste an image (Ctrl+V) into this section from your clipboard.</em></p>
</div>
<!-- Original image canvas -->
<div class="canvasWrapper">
<p id="originalSize"></p>
<canvas id="originalCanvas" width="200"></canvas>
</div>
</div>
<div class="uiContainer" id="outputArea">
<div>
<h3>Transformations</h3>
<label for="resizeWidth">Rescale original width (px):</label>
<input type="number" id="resizeWidth" size="6" min="1" placeholder="e.g. 200" value="200"><br>
<button id="rotateLeft">⟲ Rotate left</button>
<button id="rotateRight">⟳ Rotate right</button><br>
<button id="flipHorizontal">⇋ Flip horizontal</button>
<button id="flipVertical">⇵ Flip vertical</button>
<h3>Appearance</h3>
<input type="radio" name="grayModel" value="ntsc" checked><label for "grayModel" class="tooltip"> NTSC grayscale formula
<span class="tooltiptext">0.299R + 0.587G + 0.114B<br>Based on average human perception of color luminance</span></label><br>
<input type="radio" name="grayModel" value="linear"><label for="grayModel" class="tooltip"> Linear luminance
<span class="tooltiptext">0.2126R + 0.7152G + 0.0722B<br>Used by OpenSCAD surface()</span></label>
<div style="margin-top:8px;">
<label><input type="checkbox" id="invertBrightness"> Invert brightness</label>
</div>
<div style="margin-top:8px;">
<label for="blurRadius">Blur radius (pixels):</label>
<input type="number" id="blurRadius" size="5" min="0" max="20" value="0">
</div>
<h3>Output</h3>
<label><input type="checkbox" id="normalizeToUnit" checked> Normalize to [0,1] range &mdash; [0,255] if unset</label>
<div style="margin-top:8px;">
<label for="arrayName">Name of array:</label>
<input type="text" id="arrayName" value="image_array" onkeypress="return event.charCode != 32">
<div style="margin-top:8px;">
<button id="downloadButton">Save as OpenSCAD array</button>
</div>
</div>
</div>
<!-- Grayscale output image canvas -->
<div class="canvasWrapper">
<p id="grayscaleSize"></p>
<div id="outcontainer">
<canvas id="grayscaleCanvas"></canvas>
</div>
</div>
</div>
</div>
<hr>
<script>
const imageInput = document.getElementById('imageInput');
const downloadButton = document.getElementById('downloadButton');
const resizeWidthInput = document.getElementById('resizeWidth');
const originalSizeText = document.getElementById('originalSize');
const grayscaleSizeText = document.getElementById('grayscaleSize');
const invertBrightnessCheckbox = document.getElementById('invertBrightness');
const normalizeToUnitCheckbox = document.getElementById('normalizeToUnit');
const rotateLeftBtn = document.getElementById('rotateLeft');
const rotateRightBtn = document.getElementById('rotateRight');
const flipHorizontalBtn = document.getElementById('flipHorizontal');
const flipVerticalBtn = document.getElementById('flipVertical');
const blurRadiusInput = document.getElementById('blurRadius');
const arrayName = document.getElementById('arrayName');
const inputArea = document.getElementById('inputArea');
const originalCanvas = document.getElementById('originalCanvas');
const grayscaleCanvas = document.getElementById('grayscaleCanvas');
const originalCtx = originalCanvas.getContext('2d');
const grayscaleCtx = grayscaleCanvas.getContext('2d');
let grayscaleMatrix = [];
let currentImage = new Image();
let rotation = 0;
let flipH = false;
let flipV = false;
let fileSuffix = "";
function getGrayscaleModel() {
return document.querySelector('input[name="grayModel"]:checked').value;
}
function applyGaussianBlur(matrix, radius) {
if (radius <= 0) return matrix;
const kernelSize = 2 * radius + 1;
const sigma = radius > 0 ? radius / 3 : 1;
const kernel = [];
let sum = 0;
for (let i = -radius; i <= radius; i++) {
const value = Math.exp(-(i * i) / (2 * sigma * sigma));
kernel.push(value);
sum += value;
}
kernel.forEach((v, i) => kernel[i] = v / sum);
const width = matrix[0].length;
const height = matrix.length;
const horizontalBlur = [];
for (let y = 0; y < height; y++) {
horizontalBlur[y] = [];
for (let x = 0; x < width; x++) {
let val = 0;
let weightSum = 0;
for (let k = -radius; k <= radius; k++) {
const nx = x + k;
if (nx >= 0 && nx < width) {
val += matrix[y][nx] * kernel[k + radius];
weightSum += kernel[k + radius];
}
}
horizontalBlur[y][x] = val / weightSum;
}
}
const output = [];
for (let y = 0; y < height; y++) {
output[y] = [];
for (let x = 0; x < width; x++) {
let val = 0;
let weightSum = 0;
for (let k = -radius; k <= radius; k++) {
const ny = y + k;
if (ny >= 0 && ny < height) {
val += horizontalBlur[ny][x] * kernel[k + radius];
weightSum += kernel[k + radius];
}
}
output[y][x] = val / weightSum;
}
}
return output;
}
function processImage() {
if (!currentImage.src) return;
const origWidth = currentImage.naturalWidth;
const origHeight = currentImage.naturalHeight;
const thumbWidth = 200;
const thumbHeight = Math.round((origHeight / origWidth) * thumbWidth);
originalCanvas.width = thumbWidth;
originalCanvas.height = thumbHeight;
originalCtx.clearRect(0, 0, thumbWidth, thumbHeight);
originalCtx.drawImage(currentImage, 0, 0, thumbWidth, thumbHeight);
originalSizeText.textContent = `Original size: ${origWidth}×${origHeight}`;
let width = origWidth;
let height = origHeight;
const newWidth = parseInt(resizeWidthInput.value);
if (!isNaN(newWidth) && newWidth > 0) {
const aspectRatio = height / width;
width = newWidth;
height = Math.round(newWidth * aspectRatio);
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(currentImage, 0, 0, width, height);
const imgData = tempCtx.getImageData(0, 0, width, height);
const data = imgData.data;
const brightnessMatrix = [];
const model = getGrayscaleModel();
const weights = model === 'linear' ? [0.2126, 0.7152, 0.0722] : [0.299, 0.587, 0.114];
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
let brightness = weights[0] * r + weights[1] * g + weights[2] * b;
row.push(brightness);
}
brightnessMatrix.push(row);
}
const blurRadius = parseInt(blurRadiusInput.value) || 0;
const blurredMatrix = applyGaussianBlur(brightnessMatrix, blurRadius);
let min = 255;
let max = 0;
for (let y=0; y<height; y++) {
for(let x=0; x<width; x++) {
min = Math.min(min, blurredMatrix[y][x]);
max = Math.max(max, blurredMatrix[y][x]);
}
}
const range = max - min || 1;
grayscaleMatrix = [];
const grayImgData = grayscaleCtx.createImageData(width, height);
const grayData = grayImgData.data;
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
let brightness = blurredMatrix[y][x];
brightness = ((brightness - min) / range) * 255;
brightness = Math.round(brightness);
brightness = Math.max(0, Math.min(255, brightness));
if (invertBrightnessCheckbox.checked) {
brightness = 255 - brightness;
}
const i = (y * width + x) * 4;
grayData[i] = grayData[i + 1] = grayData[i + 2] = brightness;
grayData[i + 3] = 255;
row.push(brightness);
}
grayscaleMatrix.push(row);
}
const rotated = (rotation % 180 !== 0);
const finalWidth = rotated ? height : width;
const finalHeight = rotated ? width : height;
grayscaleCanvas.width = finalWidth;
grayscaleCanvas.height = finalHeight;
const tempDrawCanvas = document.createElement('canvas');
tempDrawCanvas.width = width;
tempDrawCanvas.height = height;
const tempDrawCtx = tempDrawCanvas.getContext('2d');
tempDrawCtx.putImageData(grayImgData, 0, 0);
grayscaleCtx.save();
grayscaleCtx.setTransform(1, 0, 0, 1, 0, 0);
grayscaleCtx.clearRect(0, 0, finalWidth, finalHeight);
grayscaleCtx.translate(finalWidth / 2, finalHeight / 2);
grayscaleCtx.rotate(rotation * Math.PI / 180);
grayscaleCtx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
grayscaleCtx.drawImage(tempDrawCanvas, -width / 2, -height / 2);
grayscaleCtx.restore();
grayscaleSizeText.textContent = `Output size: ${finalWidth}×${finalHeight}`;
fileSuffix = finalWidth.toString()+"x"+finalHeight.toString();
}
imageInput.addEventListener('change', function () {
const file = this.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = function (e) {
currentImage.onload = function () {
processImage();
};
currentImage.src = e.target.result;
};
reader.readAsDataURL(file);
}
});
inputArea.addEventListener('paste', function (event) {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = function (e) {
currentImage.onload = function () {
processImage();
};
currentImage.src = e.target.result;
};
reader.readAsDataURL(blob);
}
}
});
[resizeWidthInput, invertBrightnessCheckbox, normalizeToUnitCheckbox, blurRadiusInput, ...document.querySelectorAll('input[name="grayModel"]')].forEach(el => el.addEventListener('input', processImage));
rotateLeftBtn.addEventListener('click', () => { rotation = (rotation - 90 + 360) % 360; processImage(); });
rotateRightBtn.addEventListener('click', () => { rotation = (rotation + 90) % 360; processImage(); });
flipHorizontalBtn.addEventListener('click', () => { flipH = !flipH; processImage(); });
flipVerticalBtn.addEventListener('click', () => { flipV = !flipV; processImage(); });
downloadButton.addEventListener('click', () => {
if (grayscaleMatrix.length === 0) return alert("No grayscale data to save.");
const useUnit = normalizeToUnitCheckbox.checked;
const arrayContent = grayscaleMatrix.map(row => {
return " [" + row.map(val => useUnit ? (0.001 * Math.round((val / 255) * 1000)).toString().substring(0,5) : val).join(",") + "]";
}).join(",\n");
const openscadArray = (arrayName.value.length>0 ? arrayName.value : 'image_array')+" = [\n" + arrayContent + "\n];";
const blob = new Blob([openscadArray], { type: "text/plain" });
let suffix = fileSuffix.length>0 ? (arrayName.value.length>0 ? fileSuffix : "image"+fileSuffix) : "image";
let filename = arrayName.value.length>0 ? arrayName.value+'_'+suffix+'.scad' : suffix+'.scad';
if (window.showSaveFilePicker) {
saveWithFilePicker(blob, filename);
} else {
fallbackSave(blob, filename);
}
});
async function saveWithFilePicker(blob, filename) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'OpenSCAD Data File', accept: { 'text/plain': ['.scad'] } }]
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err) {
alert('Save cancelled or failed: ' + err.message);
}
}
function fallbackSave(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.target = "_blank";
a.click();
URL.revokeObjectURL(url);
}
</script>
</body>
</html>