7
7
Version 6: 23 April 2025 - added cropping UI
8
8
Version 7: 25 April 2025 - added contrast and threshold sliders
9
9
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
10
11
-->
11
- < title > Image to OpenSCAD array, v8 </ title >
12
+ < title > Image to OpenSCAD array, v9 </ title > <!-- REMEMBER TO CHANGE VERSION -- >
12
13
< meta charset ="UTF-8 ">
13
14
< style >
14
15
body { font-family : sans-serif; padding-left : 1em ; padding-right : 1em ;}
@@ -212,7 +213,7 @@ <h1>Convert image to OpenSCAD array</h1>
212
213
213
214
< fieldset >
214
215
< 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 >
216
217
< input type ="radio " name ="grayModel " value ="ntsc " checked > < label for "grayModel" class="tooltip "> NTSC grayscale formula
217
218
< span class ="tooltiptext "> NTSC Y′ = 0.299R + 0.587G + 0.114B< br > Rec. 601: Average human perception of color luminance</ span > </ label > < br >
218
219
< 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>
223
224
</ div >
224
225
< div style ="margin:8px 0; ">
225
226
< 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 ">
227
231
</ div >
228
232
229
233
< div class ="slider-row ">
@@ -287,6 +291,7 @@ <h1>Convert image to OpenSCAD array</h1>
287
291
const cropRight = document . getElementById ( 'cropRight' ) ;
288
292
const cropBottom = document . getElementById ( 'cropBottom' ) ;
289
293
const blurRadiusInput = document . getElementById ( 'blurRadius' ) ;
294
+ const sobelRadiusInput = document . getElementById ( 'sobelRadius' ) ;
290
295
const contrastInput = document . getElementById ( 'contrast' ) ;
291
296
const contrastValue = document . getElementById ( 'contrastValue' ) ;
292
297
const thresholdInput = document . getElementById ( 'threshold' ) ;
@@ -327,66 +332,113 @@ <h1>Convert image to OpenSCAD array</h1>
327
332
328
333
// image processing functions
329
334
330
- function applyGaussianBlur ( matrix , radius ) {
331
- if ( radius <= 0 ) return matrix ;
335
+ function gaussianKernel1D ( radius ) {
332
336
const sigma = radius > 0 ? radius / 3 : 1 ;
333
337
const kernel = [ ] ;
334
338
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 ) ) ;
337
341
kernel . push ( value ) ;
338
342
sum += value ;
339
343
}
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
+ }
341
356
357
+ function convolve1DHorizontal ( matrix , kernel , normalize = true ) {
342
358
const width = matrix [ 0 ] . length ;
343
359
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 = [ ] ;
346
362
for ( let y = 0 ; y < height ; y ++ ) {
347
- horizontalBlur [ y ] = [ ] ;
363
+ result [ y ] = [ ] ;
348
364
for ( let x = 0 ; x < width ; x ++ ) {
349
- let val = 0 ;
365
+ let sum = 0 ;
350
366
let weightSum = 0 ;
351
- for ( let k = - radius ; k <= radius ; k ++ ) {
367
+ for ( let k = - r ; k <= r ; k ++ ) {
352
368
const nx = x + k ;
353
369
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 ] ;
356
372
}
357
373
}
358
- horizontalBlur [ y ] [ x ] = val / weightSum ;
374
+ result [ y ] [ x ] = normalize ? ( weightSum !== 0 ? sum / weightSum : 0 ) : sum ;
359
375
}
360
376
}
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 = [ ] ;
363
385
for ( let y = 0 ; y < height ; y ++ ) {
364
- output [ y ] = [ ] ;
386
+ result [ y ] = [ ] ;
365
387
for ( let x = 0 ; x < width ; x ++ ) {
366
- let val = 0 ;
388
+ let sum = 0 ;
367
389
let weightSum = 0 ;
368
- for ( let k = - radius ; k <= radius ; k ++ ) {
390
+ for ( let k = - r ; k <= r ; k ++ ) {
369
391
const ny = y + k ;
370
392
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 ] ;
373
395
}
374
396
}
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 ;
376
412
}
377
413
}
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 ) ;
379
431
}
380
432
381
433
function sigmoid ( z ) { return 1.0 / ( 1 + Math . exp ( - z ) ) ; } // used by contrastAdj
382
434
383
435
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 ;
390
442
}
391
443
392
444
function processImage ( ) {
@@ -443,7 +495,11 @@ <h1>Convert image to OpenSCAD array</h1>
443
495
const blurRadius = parseInt ( blurRadiusInput . value ) || 0 ;
444
496
const blurredMatrix = applyGaussianBlur ( brightnessMatrix , blurRadius ) ;
445
497
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
447
503
const cropMatrix = [ ] ;
448
504
let cropx1 = parseInt ( cropID [ edgeID [ 2 ] ] . value ) || 0 ;
449
505
let cropx2 = parseInt ( cropID [ edgeID [ 0 ] ] . value ) || 0 ;
@@ -454,9 +510,9 @@ <h1>Convert image to OpenSCAD array</h1>
454
510
for ( let y = cropy1 ; y < uncropDim . height - cropy2 ; y ++ ) {
455
511
const row = [ ] ;
456
512
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 ] ) ;
460
516
}
461
517
cropMatrix . push ( row ) ;
462
518
}
@@ -524,6 +580,7 @@ <h1>Convert image to OpenSCAD array</h1>
524
580
flipV = flipH = false ;
525
581
resizeWidthInput . value = "100" ;
526
582
blurRadiusInput . value = "0" ;
583
+ sobelRadiusInput . value = "0" ;
527
584
invertBrightnessCheckbox . checked = invertBrightness = false ;
528
585
contrastInput . value = contrastValue . textContent = "0" ;
529
586
contrast = 0.0001 ;
@@ -568,7 +625,7 @@ <h1>Convert image to OpenSCAD array</h1>
568
625
569
626
// set up event listeners for all the input gadgets
570
627
571
- [ blurRadiusInput , contrastInput , thresholdInput ,
628
+ [ blurRadiusInput , sobelRadiusInput , contrastInput , thresholdInput ,
572
629
...document . querySelectorAll ( 'input[name="grayModel"]' ) ] . forEach ( el => el . addEventListener ( 'input' , processImage ) ) ;
573
630
574
631
resizeWidthInput . addEventListener ( 'input' , function ( ) {
@@ -680,21 +737,21 @@ <h1>Convert image to OpenSCAD array</h1>
680
737
processImage ( ) ;
681
738
} ) ;
682
739
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
687
744
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
690
747
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
692
749
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" ; }
698
755
const sizeOut = ( estsize / unit ) . toFixed ( unit == 1.0 ?0 :1 ) ;
699
756
kbytes . textContent = `${ sizeOut } ${ unitName } ` ;
700
757
}
@@ -714,8 +771,8 @@ <h1>Convert image to OpenSCAD array</h1>
714
771
const arrayContent = grayscaleMatrix . map ( row => {
715
772
return " [" + row . map ( val => useUnit ? parseFloat ( ( val / 255.0 ) . toFixed ( 3 ) ) : val ) . join ( "," ) + "]" ;
716
773
} ) . 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
719
776
const openscadArray = ( arrayName . value . length > 0 ? arrayName . value : 'image_array' ) + introcomment + arrayContent + "\n];" ;
720
777
const blob = new Blob ( [ openscadArray ] , { type : "text/plain" } ) ;
721
778
let filename = ( arrayName . value . length > 0 ? arrayName . value : "image_array" ) + dimSuffix + '.scad' ;
0 commit comments