Skip to content

Commit bba8874

Browse files
committed
Added edge detection to img2scad.html
1 parent d92dfbc commit bba8874

File tree

1 file changed

+107
-50
lines changed

1 file changed

+107
-50
lines changed

scripts/img2scad.html

Lines changed: 107 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
Version 6: 23 April 2025 - added cropping UI
88
Version 7: 25 April 2025 - added contrast and threshold sliders
99
Version 8: 26 April 2025 - added file size estimate to output section
10+
Version 9: 20 May 2025 - improved appearance UI, added Sobel edge detection
1011
-->
11-
<title>Image to OpenSCAD array, v8</title>
12+
<title>Image to OpenSCAD array, v9</title><!-- REMEMBER TO CHANGE VERSION -->
1213
<meta charset="UTF-8">
1314
<style>
1415
body { font-family: sans-serif; padding-left:1em; padding-right:1em;}
@@ -212,7 +213,7 @@ <h1>Convert image to OpenSCAD array</h1>
212213

213214
<fieldset>
214215
<legend>Appearance</legend>
215-
<div style="float:right; border:1px solid green; padding:4px; text-align:center; font-size:smaller;">See:<br><a href="https://en.wikipedia.org/wiki/Luma_(video)" target="_blank">Luma</a></div>
216+
<div style="float:right; border:1px solid green; padding:4px; text-align:center; font-size:smaller;">See:<br><a href="https://en.wikipedia.org/wiki/Luma_(video)" target="_blank">Luma</a></div>
216217
<input type="radio" name="grayModel" value="ntsc" checked><label for "grayModel" class="tooltip"> NTSC grayscale formula
217218
<span class="tooltiptext">NTSC Y&prime; = 0.299R + 0.587G + 0.114B<br>Rec. 601: Average human perception of color luminance</span></label><br>
218219
<input type="radio" name="grayModel" value="linear"><label for="grayModel" class="tooltip"> sRGB linear luminance
@@ -223,7 +224,10 @@ <h1>Convert image to OpenSCAD array</h1>
223224
</div>
224225
<div style="margin:8px 0;">
225226
<label for="blurRadius">Gaussian blur radius (pixels):</label>
226-
<input type="number" id="blurRadius" size="5" min="0" max="20" value="0">
227+
<input type="number" id="blurRadius" size="5" min="0" max="20" value="0"><br>
228+
<label for="sobelRadius" class="tooltip">Edge detect radius (pixels):
229+
<span class="tooltiptext">Sobel filter uses own radius if Gaussian blur=0</span></label>
230+
<input type="number" id="sobelRadius" size="5" min="0" max="20" value="0">
227231
</div>
228232

229233
<div class="slider-row">
@@ -287,6 +291,7 @@ <h1>Convert image to OpenSCAD array</h1>
287291
const cropRight = document.getElementById('cropRight');
288292
const cropBottom = document.getElementById('cropBottom');
289293
const blurRadiusInput = document.getElementById('blurRadius');
294+
const sobelRadiusInput = document.getElementById('sobelRadius');
290295
const contrastInput = document.getElementById('contrast');
291296
const contrastValue = document.getElementById('contrastValue');
292297
const thresholdInput = document.getElementById('threshold');
@@ -327,66 +332,113 @@ <h1>Convert image to OpenSCAD array</h1>
327332

328333
// image processing functions
329334

330-
function applyGaussianBlur(matrix, radius) {
331-
if (radius <= 0) return matrix;
335+
function gaussianKernel1D(radius) {
332336
const sigma = radius > 0 ? radius / 3 : 1;
333337
const kernel = [];
334338
let sum = 0;
335-
for (let i = -radius; i <= radius; i++) { // kernel size = 2 * radius + 1;
336-
const value = Math.exp(-(i * i) / (2 * sigma * sigma));
339+
for (let i = -radius; i <= radius; i++) {
340+
const value = Math.exp(- (i * i) / (2 * sigma * sigma));
337341
kernel.push(value);
338342
sum += value;
339343
}
340-
kernel.forEach((v, i) => kernel[i] = v / sum);
344+
return kernel.map(v => v / sum);
345+
}
346+
347+
function sobelDerivativeKernel(size) {
348+
const half = Math.floor(size / 2);
349+
const kernel = [];
350+
for (let i = -half; i <= half; i++) {
351+
kernel.push(i);
352+
}
353+
const norm = kernel.reduce((acc, val) => acc + Math.abs(val), 0) || 1;
354+
return kernel.map(v => v / norm);
355+
}
341356

357+
function convolve1DHorizontal(matrix, kernel, normalize=true) {
342358
const width = matrix[0].length;
343359
const height = matrix.length;
344-
const horizontalBlur = [];
345-
// blur pixels horizontally, put in horizontalBlur[]
360+
const r = Math.floor(kernel.length / 2);
361+
const result = [];
346362
for (let y = 0; y < height; y++) {
347-
horizontalBlur[y] = [];
363+
result[y] = [];
348364
for (let x = 0; x < width; x++) {
349-
let val = 0;
365+
let sum = 0;
350366
let weightSum = 0;
351-
for (let k = -radius; k <= radius; k++) {
367+
for (let k = -r; k <= r; k++) {
352368
const nx = x + k;
353369
if (nx >= 0 && nx < width) {
354-
val += matrix[y][nx] * kernel[k + radius];
355-
weightSum += kernel[k + radius];
370+
sum += matrix[y][nx] * kernel[k+r];
371+
weightSum += kernel[k+r];
356372
}
357373
}
358-
horizontalBlur[y][x] = val / weightSum;
374+
result[y][x] = normalize ? (weightSum !== 0 ? sum / weightSum : 0) : sum;
359375
}
360376
}
361-
// blur pixels vertically in horizontalBlur[], return result in output[]
362-
const output = [];
377+
return result;
378+
}
379+
380+
function convolve1DVertical(matrix, kernel, normalize=true) {
381+
const width = matrix[0].length;
382+
const height = matrix.length;
383+
const r = Math.floor(kernel.length / 2);
384+
const result = [];
363385
for (let y = 0; y < height; y++) {
364-
output[y] = [];
386+
result[y] = [];
365387
for (let x = 0; x < width; x++) {
366-
let val = 0;
388+
let sum = 0;
367389
let weightSum = 0;
368-
for (let k = -radius; k <= radius; k++) {
390+
for (let k = -r; k <= r; k++) {
369391
const ny = y + k;
370392
if (ny >= 0 && ny < height) {
371-
val += horizontalBlur[ny][x] * kernel[k + radius];
372-
weightSum += kernel[k + radius];
393+
sum += matrix[ny][x] * kernel[k+r];
394+
weightSum += kernel[k+r];
373395
}
374396
}
375-
output[y][x] = val / weightSum;
397+
result[y][x] = normalize ? (weightSum !== 0 ? sum / weightSum : 0) : sum;
398+
}
399+
}
400+
return result;
401+
}
402+
403+
function computeEdgeMagnitude(gx, gy) {
404+
const height = gx.length;
405+
const width = gx[0].length;
406+
const result = [];
407+
for (let y = 0; y < height; y++) {
408+
result[y] = [];
409+
for (let x = 0; x < width; x++) {
410+
const mag = Math.sqrt(gx[y][x] ** 2 + gy[y][x] ** 2);
411+
result[y][x] = mag;
376412
}
377413
}
378-
return output;
414+
return result;
415+
}
416+
417+
function applyGaussianBlur(matrix, blurRadius) {
418+
const gKernel = gaussianKernel1D(blurRadius)
419+
g1 = convolve1DVertical(matrix, gKernel);
420+
return convolve1DHorizontal(g1, gKernel);
421+
}
422+
423+
function applySobel(matrix, sobelRadius, blurRadius) {
424+
if (sobelRadius <= 0) return matrix; // No edge detection
425+
const sobelSize = 2 * sobelRadius + 1;
426+
const dKernel = sobelDerivativeKernel(sobelSize);
427+
let gblur = blurRadius === 0 ? applyGaussianBlur(matrix, sobelRadius) : matrix;
428+
gx = convolve1DHorizontal(gblur, dKernel, false);
429+
gy = convolve1DVertical(gblur, dKernel, false);
430+
return computeEdgeMagnitude(gx, gy);
379431
}
380432

381433
function sigmoid(z) { return 1.0 / (1+Math.exp(-z)); } // used by contrastAdj
382434

383435
function contrastAdj(brightness) { // return an adjusted brightness based on contrast and threshold
384-
const x = brightness/255.0;
385-
const c = 2.0*contrast; // attempt to balance the sigmoid response to the contrast control
386-
const sigterm = sigmoid(-c*threshold);
387-
const adj = contrast>100.0 ? (x<threshold ? 0 : x>threshold ? 1 : threshold) // jump to 100% contrast at max contrast
388-
: (sigmoid(c*(x-threshold)) - sigterm) / (sigmoid(c*(1.0-threshold)) - sigterm);
389-
return adj * 255.0;
436+
const x = brightness/255.0;
437+
const c = 2.0*contrast; // attempt to balance the sigmoid response to the contrast control
438+
const sigterm = sigmoid(-c*threshold);
439+
const adj = contrast>100.0 ? (x<threshold ? 0 : x>threshold ? 1 : threshold) // jump to 100% contrast at max contrast
440+
: (sigmoid(c*(x-threshold)) - sigterm) / (sigmoid(c*(1.0-threshold)) - sigterm);
441+
return adj * 255.0;
390442
}
391443

392444
function processImage() {
@@ -443,7 +495,11 @@ <h1>Convert image to OpenSCAD array</h1>
443495
const blurRadius = parseInt(blurRadiusInput.value) || 0;
444496
const blurredMatrix = applyGaussianBlur(brightnessMatrix, blurRadius);
445497

446-
// crop the blurred matrix, gather min and max values in crop area
498+
// apply Sobel edge detection
499+
const sobelRadius = parseInt(sobelRadiusInput.value) || 0;
500+
const sobelMatrix = applySobel(blurredMatrix, sobelRadius, blurRadius);
501+
502+
// crop the matrix, gather min and max values in crop area
447503
const cropMatrix = [];
448504
let cropx1 = parseInt(cropID[edgeID[2]].value) || 0;
449505
let cropx2 = parseInt(cropID[edgeID[0]].value) || 0;
@@ -454,9 +510,9 @@ <h1>Convert image to OpenSCAD array</h1>
454510
for (let y=cropy1; y<uncropDim.height-cropy2; y++) {
455511
const row = [];
456512
for(let x=cropx1; x<uncropDim.width-cropx2; x++) {
457-
row.push(blurredMatrix[y][x]);
458-
min = Math.min(min, blurredMatrix[y][x]);
459-
max = Math.max(max, blurredMatrix[y][x]);
513+
row.push(sobelMatrix[y][x]);
514+
min = Math.min(min, sobelMatrix[y][x]);
515+
max = Math.max(max, sobelMatrix[y][x]);
460516
}
461517
cropMatrix.push(row);
462518
}
@@ -524,6 +580,7 @@ <h1>Convert image to OpenSCAD array</h1>
524580
flipV = flipH = false;
525581
resizeWidthInput.value = "100";
526582
blurRadiusInput.value = "0";
583+
sobelRadiusInput.value = "0";
527584
invertBrightnessCheckbox.checked = invertBrightness = false;
528585
contrastInput.value = contrastValue.textContent = "0";
529586
contrast = 0.0001;
@@ -568,7 +625,7 @@ <h1>Convert image to OpenSCAD array</h1>
568625

569626
// set up event listeners for all the input gadgets
570627

571-
[blurRadiusInput, contrastInput, thresholdInput,
628+
[blurRadiusInput, sobelRadiusInput, contrastInput, thresholdInput,
572629
...document.querySelectorAll('input[name="grayModel"]')].forEach(el => el.addEventListener('input', processImage));
573630

574631
resizeWidthInput.addEventListener('input', function () {
@@ -680,21 +737,21 @@ <h1>Convert image to OpenSCAD array</h1>
680737
processImage();
681738
});
682739

683-
const Gbyte = 1073741824.0;
684-
const Mbyte = 1048576.0;
685-
const Kbyte = 1024.0;
686-
// update file size estimate based on normalize type and size of output image
740+
const Gbyte = 1073741824.0;
741+
const Mbyte = 1048576.0;
742+
const Kbyte = 1024.0;
743+
// update file size estimate based on normalize type and size of output image
687744
function updateKbytes() {
688-
// length of a number for [0,1] range: mostly 6 characters "0.xxx," but occasionally less, using 5.95.
689-
// length of a number for [0,255] range: assume 0-255 are uniformly distributed, use weighted average of digits plus comma
745+
// length of a number for [0,1] range: mostly 6 characters "0.xxx," but occasionally less, using 5.95.
746+
// length of a number for [0,255] range: assume 0-255 are uniformly distributed, use weighted average of digits plus comma
690747
const avglen = normalizeToUnitCheckbox.checked ? 5.95 : (10.0+90.0*2.0+156.0*3.0)/256.0+1.0;
691-
// each row has 6 extra characters " [],\r\n" at most, plus 5 characters after array name and 4 characters at the end
748+
// each row has 6 extra characters " [],\r\n" at most, plus 5 characters after array name and 4 characters at the end
692749
const estsize = (avglen*cropDim.width + 6.0) * cropDim.height + 9 + arrayName.value.length;
693-
let unitName = "bytes";
694-
let unit = 1.0;
695-
if (estsize > Gbyte) { unit = Gbyte; unitName = "GiB"; }
696-
else if (estsize > Mbyte) { unit = Mbyte; unitName = "MiB"; }
697-
else if (estsize > 10.0*Kbyte) { unit = Kbyte; unitName = "KiB"; }
750+
let unitName = "bytes";
751+
let unit = 1.0;
752+
if (estsize > Gbyte) { unit = Gbyte; unitName = "GiB"; }
753+
else if (estsize > Mbyte) { unit = Mbyte; unitName = "MiB"; }
754+
else if (estsize > 10.0*Kbyte) { unit = Kbyte; unitName = "KiB"; }
698755
const sizeOut = (estsize/unit).toFixed(unit==1.0?0:1);
699756
kbytes.textContent = `${sizeOut} ${unitName}`;
700757
}
@@ -714,8 +771,8 @@ <h1>Convert image to OpenSCAD array</h1>
714771
const arrayContent = grayscaleMatrix.map(row => {
715772
return " [" + row.map(val => useUnit ? parseFloat((val/255.0).toFixed(3)) : val).join(",") + "]";
716773
}).join(",\n");
717-
const introcomment = " = [ // " + cropDim.width + "×" + cropDim.height + "\n";
718-
const dimSuffix = "_"+cropDim.width + "x" + cropDim.height
774+
const introcomment = " = [ // " + cropDim.width + "×" + cropDim.height + "\n";
775+
const dimSuffix = "_"+cropDim.width + "x" + cropDim.height
719776
const openscadArray = (arrayName.value.length>0 ? arrayName.value : 'image_array') + introcomment + arrayContent + "\n];";
720777
const blob = new Blob([openscadArray], { type: "text/plain" });
721778
let filename = (arrayName.value.length>0 ? arrayName.value : "image_array") + dimSuffix + '.scad';

0 commit comments

Comments
 (0)