Contrast+threshold controls for img2scad.html

This commit is contained in:
Alex Matulich
2025-04-25 10:54:54 -07:00
parent d0216db0ed
commit 49909ec6a3

View File

@@ -5,12 +5,48 @@
Standalone web app to convert an image file to an OpenSCAD array, for use with BOSL2 textures. 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 Versions 1-5: 22 April 2025 - by Alex Matulich
Version 6: 23 April 2025 - added cropping UI Version 6: 23 April 2025 - added cropping UI
Version 7: 25 April 2025 - added contrast and threshold sliders
--> -->
<title>Image to OpenSCAD array, v6</title> <title>Image to OpenSCAD array, v7</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<style> <style>
body { font-family: sans-serif; padding-left:1em; padding-right:1em;} body { font-family: sans-serif; padding-left:1em; padding-right:1em;}
h1,h2,h3,h4 { font-family: serif; } h1,h2,h3,h4 { font-family: serif; }
fieldset {
border: 2px ridge silver;
border-radius: 8px;
margin-bottom: 8px;
min-width: 300px;
}
legend {
font-weight: bold;
font-family: Serif;
font-size: larger;
padding: 0 6px;
}
.slider-row {
display: flex;
align-items: center;
}
.slider-label {
width: 9ch;
}
.slider-container {
flex: 1;
display: flex;
align-items: center;
gap: 1ch;
min-width: 0;
}
input[type="range"] {
flex: 1;
min-width: 0;
}
.slider-value {
width: 4ch;
text-align: right;
}
.uiContainer { .uiContainer {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -121,16 +157,17 @@ Version 6: 23 April 2025 - added cropping UI
</head> </head>
<body> <body>
<h1>Convert image to OpenSCAD array</h1> <h1>Convert image to OpenSCAD array</h1>
<p>This utility accepts any image that can be displayed in your browser, and converts it to grayscale expanded to use the maximum <p>This utility accepts an image that can be displayed in your browser, and converts it to grayscale
possible luminance range. Alpha channel is ignored. After processing the image as desired, you may save it as an OpenSCAD array.</p> expanded to use the maximum possible luminance range. The file types supported depend on your browser.
Alpha channel is ignored. After processing the image as desired, you may save it as an OpenSCAD array.</p>
<hr> <hr>
<div id="content"> <div id="content">
<div class="uiContainer" id="inputArea" tabindex="0"> <div class="uiContainer" id="inputArea" tabindex="0">
<div> <fieldset>
<h3>Select an image</h2> <legend>Select an image</legend>
<input type="file" id="imageInput" accept="image/*"> <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> <p><em>You can also paste an image (Ctrl+V) into this section from your clipboard.</em></p>
</div> </fieldset>
<!-- Original image canvas --> <!-- Original image canvas -->
<div class="canvasWrapper"> <div class="canvasWrapper">
<p id="originalSize"></p> <p id="originalSize"></p>
@@ -140,7 +177,8 @@ possible luminance range. Alpha channel is ignored. After processing the image a
<div class="uiContainer" id="outputArea"> <div class="uiContainer" id="outputArea">
<div> <div>
<h3>Transformations</h3> <fieldset>
<legend>Transformations</legend>
<label for="resizeWidth">Rescale original width (px):</label> <label for="resizeWidth">Rescale original width (px):</label>
<input type="number" id="resizeWidth" size="5" min="1" max="9000" value="100"><br> <input type="number" id="resizeWidth" size="5" min="1" max="9000" value="100"><br>
<button id="rotateLeft">⟲ Rotate left</button> <button id="rotateLeft">⟲ Rotate left</button>
@@ -167,8 +205,10 @@ possible luminance range. Alpha channel is ignored. After processing the image a
<label for="crop-bottom">Bottom</label> <label for="crop-bottom">Bottom</label>
</div> </div>
</div> </div>
</fieldset>
<h3>Appearance</h3> <fieldset>
<legend>Appearance</legend>
<input type="radio" name="grayModel" value="ntsc" checked><label for "grayModel" class="tooltip"> NTSC grayscale formula <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> <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 <input type="radio" name="grayModel" value="linear"><label for="grayModel" class="tooltip"> Linear luminance
@@ -177,13 +217,32 @@ possible luminance range. Alpha channel is ignored. After processing the image a
<div style="margin-top:8px;"> <div style="margin-top:8px;">
<label><input type="checkbox" id="invertBrightness"> Invert brightness</label> <label><input type="checkbox" id="invertBrightness"> Invert brightness</label>
</div> </div>
<div style="margin-top:8px;"> <div style="margin:8px 0;">
<label for="blurRadius">Blur radius (pixels):</label> <label for="blurRadius">Gaussian blur radius (pixels):</label>
<input type="number" id="blurRadius" size="5" min="0" max="20" value="0"> <input type="number" id="blurRadius" size="5" min="0" max="20" value="0">
</div> </div>
<h3>Output</h3> <div class="slider-row">
<label><input type="checkbox" id="normalizeToUnit" checked> Normalize to [0,1] range &mdash; [0,255] if unset</label> <label for="contrast" class="slider-label tooltip">Contrast
<span class="tooltiptext">Compress brightness above and below threshold<br>to maximum and minimum brightness.</span></label>
<div class="slider-container">
<input type="range" id="contrast" min="0" max="100" value="0">
<span id="contrastValue" class="slider-value">0</span>
</div>
</div>
<div class="slider-row">
<label for="threshold" class="slider-label tooltip">Threshold
<span class="tooltiptext">Level between black (-128) and white (128)<br>around which to adjust contrast..</span></label>
<div class="slider-container">
<input type="range" id="threshold" min="-128" max="127" value="0">
<span id="thresholdValue" class="slider-value">0</span>
</div>
</div>
</fieldset>
<fieldset>
<legend>Output</legend>
<label><input type="checkbox" id="normalizeToUnit" checked> Normalize range to [0,1] &mdash; uses [0,255] if unset</label>
<div style="margin-top:8px;"> <div style="margin-top:8px;">
<label for="arrayName">Name of array:</label> <label for="arrayName">Name of array:</label>
<input type="text" id="arrayName" value="image_array" onkeypress="return event.charCode != 32"> <input type="text" id="arrayName" value="image_array" onkeypress="return event.charCode != 32">
@@ -191,6 +250,7 @@ possible luminance range. Alpha channel is ignored. After processing the image a
<button id="downloadButton">Save as OpenSCAD array</button> <button id="downloadButton">Save as OpenSCAD array</button>
</div> </div>
</div> </div>
</fieldset>
</div> </div>
<!-- Grayscale output image canvas --> <!-- Grayscale output image canvas -->
<div class="canvasWrapper"> <div class="canvasWrapper">
@@ -223,6 +283,10 @@ possible luminance range. Alpha channel is ignored. After processing the image a
const cropRight = document.getElementById('cropRight'); const cropRight = document.getElementById('cropRight');
const cropBottom = document.getElementById('cropBottom'); const cropBottom = document.getElementById('cropBottom');
const blurRadiusInput = document.getElementById('blurRadius'); const blurRadiusInput = document.getElementById('blurRadius');
const contrastInput = document.getElementById('contrast');
const contrastValue = document.getElementById('contrastValue');
const thresholdInput = document.getElementById('threshold');
const thresholdValue = document.getElementById('thresholdValue');
const arrayName = document.getElementById('arrayName'); const arrayName = document.getElementById('arrayName');
const inputArea = document.getElementById('inputArea'); const inputArea = document.getElementById('inputArea');
const originalCanvas = document.getElementById('originalCanvas'); const originalCanvas = document.getElementById('originalCanvas');
@@ -253,13 +317,12 @@ possible luminance range. Alpha channel is ignored. After processing the image a
let origDim = { width:0, height:0 }; let origDim = { width:0, height:0 };
let uncropDim = { width:0, height:0 }; let uncropDim = { width:0, height:0 };
let cropDim = { width:0, height:0 }; let cropDim = { width:0, height:0 };
let invertBrightness = false;
let contrast = 0.0001; // ranges from 0.0001 to 100.0001
let threshold = 128.0/255.0; // ranges from 0. to 1.0
// image processing functions // image processing functions
function getGrayscaleModel() {
return document.querySelector('input[name="grayModel"]:checked').value;
}
function applyGaussianBlur(matrix, radius) { function applyGaussianBlur(matrix, radius) {
if (radius <= 0) return matrix; if (radius <= 0) return matrix;
@@ -313,6 +376,15 @@ possible luminance range. Alpha channel is ignored. After processing the image a
return output; return output;
} }
function sigmoid(z) { return 1.0 / (1+Math.exp(-z)); } // used by contrastAdj
function contrastAdj(brightness) { // return an adjusted brightness based on contrast and threshold
const x = brightness/255.0;
const sigterm = sigmoid(-contrast*threshold);
const adj = (sigmoid(contrast*(x-threshold)) - sigterm) / (sigmoid(contrast*(1.0-threshold)) - sigterm);
return adj * 255.0;
}
function processImage() { function processImage() {
if (!currentImage.src) return; if (!currentImage.src) return;
@@ -348,7 +420,7 @@ possible luminance range. Alpha channel is ignored. After processing the image a
// convert image data to grayscale // convert image data to grayscale
const brightnessMatrix = []; const brightnessMatrix = [];
const model = getGrayscaleModel(); const model = document.querySelector('input[name="grayModel"]:checked').value;
const weights = model === 'linear' ? [0.2126, 0.7152, 0.0722] : [0.299, 0.587, 0.114]; const weights = model === 'linear' ? [0.2126, 0.7152, 0.0722] : [0.299, 0.587, 0.114];
for (let y = 0; y < uncropDim.height; y++) { for (let y = 0; y < uncropDim.height; y++) {
const row = []; const row = [];
@@ -388,6 +460,7 @@ possible luminance range. Alpha channel is ignored. After processing the image a
cropDim.height = uncropDim.height - cropy1 - cropy2; cropDim.height = uncropDim.height - cropy1 - cropy2;
// normalize cropped image brightness to 0-255 range, invert brightness if checkbox is selected // normalize cropped image brightness to 0-255 range, invert brightness if checkbox is selected
// adjust contrast if needed
const range = max - min || 1; const range = max - min || 1;
grayscaleMatrix = []; grayscaleMatrix = [];
const grayImgData = grayscaleCtx.createImageData(cropDim.width, cropDim.height); const grayImgData = grayscaleCtx.createImageData(cropDim.width, cropDim.height);
@@ -397,11 +470,11 @@ possible luminance range. Alpha channel is ignored. After processing the image a
for (let x = 0; x < cropDim.width; x++) { for (let x = 0; x < cropDim.width; x++) {
let brightness = cropMatrix[y][x]; let brightness = cropMatrix[y][x];
brightness = ((brightness - min) / range) * 255; brightness = ((brightness - min) / range) * 255;
brightness = Math.round(brightness); if (contrast>0.0002)
brightness = Math.max(0, Math.min(255, brightness)); brightness = contrastAdj(brightness);
if (invertBrightnessCheckbox.checked) { if (invertBrightness)
brightness = 255 - brightness; brightness = 255 - brightness;
} brightness = Math.max(0, Math.min(255, Math.round(brightness)));
const i = (y * cropDim.width + x) * 4; const i = (y * cropDim.width + x) * 4;
grayData[i] = grayData[i+1] = grayData[i+2] = brightness; grayData[i] = grayData[i+1] = grayData[i+2] = brightness;
grayData[i + 3] = 255; grayData[i + 3] = 255;
@@ -447,7 +520,11 @@ possible luminance range. Alpha channel is ignored. After processing the image a
flipV = flipH = false; flipV = flipH = false;
resizeWidthInput.value = "100"; resizeWidthInput.value = "100";
blurRadiusInput.value = "0"; blurRadiusInput.value = "0";
invertBrightnessCheckbox.checked = false; invertBrightnessCheckbox.checked = invertBrightness = false;
contrastInput.value = contrastValue.textContent = "0";
contrast = 0.0001;
thresholdInput.value = thresholdValue.textContent = "0";
threshold = 128.0/255.0;
} }
// user pressed button to load image from disk // user pressed button to load image from disk
@@ -485,16 +562,25 @@ possible luminance range. Alpha channel is ignored. After processing the image a
} }
}); });
// set up events for all the input gadgets // set up event listeners for all the input gadgets
[resizeWidthInput, invertBrightnessCheckbox, normalizeToUnitCheckbox, blurRadiusInput, [normalizeToUnitCheckbox, blurRadiusInput,
contrastInput, thresholdInput,
...document.querySelectorAll('input[name="grayModel"]')].forEach(el => el.addEventListener('input', processImage)); ...document.querySelectorAll('input[name="grayModel"]')].forEach(el => el.addEventListener('input', processImage));
resizeWidthInput.addEventListener('input', function () {
let min = parseInt(this.min);
if (parseInt(this.value) < min) this.value = min;
processImage();
});
cropLeft.addEventListener('input', () => { cropLeft.addEventListener('input', () => {
if (!currentImage.src) { cropLeft.value="0"; return; } if (!currentImage.src) { cropLeft.value="0"; return; }
const cl = parseInt(cropLeft.value) || 0; const cl = parseInt(cropLeft.value) || 0;
const cr = parseInt(cropRight.value) || 0; const cr = parseInt(cropRight.value) || 0;
if(uncropDim.width - cl - cr < 2) cropLeft.value = (uncropDim.width - cr - 2).toString(); const newcl = uncropDim.width - cl - cr < 2 ? uncropDim.width - cr - 2 : cl;
cropLeft.value = newcl.toString();
resizeWidthInput.min = newcl + cr + 2;
processImage(); processImage();
}); });
cropTop.addEventListener('input', () => { cropTop.addEventListener('input', () => {
@@ -508,7 +594,9 @@ possible luminance range. Alpha channel is ignored. After processing the image a
if (!currentImage.src) { cropRight.value="0"; return; } if (!currentImage.src) { cropRight.value="0"; return; }
const cl = parseInt(cropLeft.value) || 0; const cl = parseInt(cropLeft.value) || 0;
const cr = parseInt(cropRight.value) || 0; const cr = parseInt(cropRight.value) || 0;
if(uncropDim.width - cl - cr < 2) cropRight.value = (uncropDim.width - cl - 2).toString(); const newcr = uncropDim.width - cl - cr < 2 ? uncropDim.width - cl - 2 : cr;
cropRight.value = newcr.toString();
resizeWidthInput.min = cl + newcr + 2;
processImage(); processImage();
}); });
cropBottom.addEventListener('input', () => { cropBottom.addEventListener('input', () => {
@@ -567,6 +655,28 @@ possible luminance range. Alpha channel is ignored. After processing the image a
processImage(); processImage();
}); });
invertBrightnessCheckbox.addEventListener('input', () => {
if (invertBrightness != invertBrightnessCheckbox.checked) {
const t = Math.min(127, -parseInt(thresholdInput.value));
threshold = (128.0+t)/255.0;
thresholdInput.value = thresholdValue.textContent = t.toString();
}
invertBrightness = invertBrightnessCheckbox.checked;
processImage();
});
contrastInput.addEventListener('input', function() {
contrastValue.textContent = this.value;
const c = parseFloat(this.value);
contrast = c*c/100.0 + 0.0001;
processImage();
});
thresholdInput.addEventListener('input', function() {
thresholdValue.textContent = this.value;
threshold = (parseFloat(this.value) + 128.0) / 255.0;
processImage();
});
// saving the file - try to use "Save As" file picker, // saving the file - try to use "Save As" file picker,
// fall back to saving with a default name to browser's downloads directory. // fall back to saving with a default name to browser's downloads directory.
@@ -574,7 +684,7 @@ possible luminance range. Alpha channel is ignored. After processing the image a
if (grayscaleMatrix.length === 0) return alert("No data to save."); if (grayscaleMatrix.length === 0) return alert("No data to save.");
const useUnit = normalizeToUnitCheckbox.checked; const useUnit = normalizeToUnitCheckbox.checked;
const arrayContent = grayscaleMatrix.map(row => { const arrayContent = grayscaleMatrix.map(row => {
return " [" + row.map(val => useUnit ? parseFloat(val/255).toFixed(3) : val).join(",") + "]"; return " [" + row.map(val => useUnit ? parseFloat(val/255.0).toFixed(3) : val).join(",") + "]";
}).join(",\n"); }).join(",\n");
const openscadArray = (arrayName.value.length>0 ? arrayName.value : 'image_array')+" = [\n" + arrayContent + "\n];"; const openscadArray = (arrayName.value.length>0 ? arrayName.value : 'image_array')+" = [\n" + arrayContent + "\n];";
const blob = new Blob([openscadArray], { type: "text/plain" }); const blob = new Blob([openscadArray], { type: "text/plain" });