mirror of
https://github.com/revarbat/BOSL2.git
synced 2025-08-06 12:16:29 +02:00
859 lines
32 KiB
HTML
859 lines
32 KiB
HTML
<!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 (collaborating with ChatGPT for crop panel CSS, file loading and saving, and Gaussian blur)
|
||
Version 6: 23 April 2025 - added cropping UI
|
||
Version 7: 25 April 2025 - added contrast and threshold sliders
|
||
Version 8: 26 April 2025 - added file size estimate to output section
|
||
Version 9: 20 May 2025 - improved appearance UI, added Sobel edge detection
|
||
Version 10: 21 May 2025 - Added array_name_size value at top of output file
|
||
Version 11: 22 May 2025 - Fixed filter artifacts at image edges, added sharpening filter
|
||
Version 12: 30 May 2025 - Made filters mutually exclusive
|
||
-->
|
||
<title>Image to OpenSCAD array, v12</title><!-- REMEMBER TO CHANGE VERSION -->
|
||
<meta charset="UTF-8">
|
||
<style>
|
||
body { font-family: sans-serif; padding-left:1em; padding-right:1em;}
|
||
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;
|
||
}
|
||
|
||
input[type="range"] {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.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;
|
||
}
|
||
.slider-value {
|
||
width: 4ch;
|
||
text-align: right;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* cropping control panel stuff */
|
||
|
||
.crop-container {
|
||
display: grid;
|
||
grid-template-areas:
|
||
"top top top"
|
||
"left center right"
|
||
"bottom bottom bottom";
|
||
grid-template-columns: auto 60px auto;
|
||
grid-template-rows: auto 60px auto;
|
||
gap: 4px;
|
||
padding: 2px;
|
||
box-sizing: border-box;
|
||
width: fit-content;
|
||
height: fit-content;
|
||
margin-top: 8px;
|
||
}
|
||
.crop-center {
|
||
grid-area: center;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 60px;
|
||
height: 60px;
|
||
border: 2px dashed #ccc;
|
||
background-color: #eee;
|
||
font-weight: bold;
|
||
font-size: 1rem;
|
||
box-sizing: border-box;
|
||
}
|
||
.crop-control {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0.9rem;
|
||
}
|
||
.crop-control input[type="number"] {
|
||
width: 6ch;
|
||
padding: 2px;
|
||
font-size: 1rem;
|
||
text-align: right;
|
||
border: 1px solid #ccc;
|
||
border-radius: 3px;
|
||
}
|
||
.crop-top { grid-area: top; }
|
||
.crop-left { grid-area: left; }
|
||
.crop-right { grid-area: right; }
|
||
.crop-bottom {
|
||
grid-area: bottom;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Convert image to OpenSCAD array</h1>
|
||
<p>This utility accepts an image that can be displayed in your browser, and converts it to grayscale
|
||
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>
|
||
<p>Keep the output image width small! A large size results in a huge output file when converting an image to text data.</p>
|
||
<hr>
|
||
<div id="content">
|
||
<div class="uiContainer" id="inputArea" tabindex="0">
|
||
<fieldset>
|
||
<legend>Select an image</legend>
|
||
<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>
|
||
</fieldset>
|
||
<!-- Original image canvas -->
|
||
<div class="canvasWrapper">
|
||
<p id="originalSize"></p>
|
||
<canvas id="originalCanvas" width="200"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="uiContainer" id="outputArea">
|
||
<div>
|
||
<fieldset>
|
||
<legend>Transformations</legend>
|
||
<label for="resizeWidth">Rescale original width (px):</label>
|
||
<input type="number" id="resizeWidth" size="5" min="1" max="9000" value="100"><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>
|
||
|
||
<div class="crop-container">
|
||
<div class="crop-control crop-top">
|
||
<label for="crop-top">Top</label>
|
||
<input type="number" id="cropTop" min="0" max="9999" value="0">
|
||
</div>
|
||
<div class="crop-control crop-left">
|
||
<label for="crop-left">Left</label>
|
||
<input type="number" id="cropLeft" min="0" max="9999" value="0">
|
||
</div>
|
||
<div class="crop-center">Crop</div>
|
||
<div class="crop-control crop-right">
|
||
<label for="crop-right">Right</label>
|
||
<input type="number" id="cropRight" min="0" max="9999" value="0">
|
||
</div>
|
||
<div class="crop-control crop-bottom">
|
||
<input type="number" id="cropBottom" min="0" max="9999" value="0">
|
||
<label for="crop-bottom">Bottom</label>
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<fieldset>
|
||
<legend>Appearance</legend>
|
||
<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>
|
||
<input type="radio" name="grayModel" value="ntsc" checked><label for "grayModel" class="tooltip"> NTSC grayscale formula
|
||
<span class="tooltiptext">NTSC Y′ = 0.299R + 0.587G + 0.114B<br>Rec. 601: Average human perception of color luminance</span></label><br>
|
||
<input type="radio" name="grayModel" value="linear"><label for="grayModel" class="tooltip"> sRGB linear luminance
|
||
<span class="tooltiptext">sRGB Y′ = 0.2126R + 0.7152G + 0.0722B<br>Rec. 709: Digital HD, used by OpenSCAD surface()</span></label>
|
||
|
||
<div style="margin-top:8px;">
|
||
<label><input type="checkbox" id="invertBrightness"> Invert brightness</label>
|
||
</div>
|
||
<fieldset style="margin:8px 0;">
|
||
<legend style="font-size:medium;">Filter</legend>
|
||
<input type="radio" name="filterSelect" value="blur" checked>
|
||
<label for="blurRadius">Gaussian blur radius (pixels):</label>
|
||
<input type="number" id="blurRadius" size="5" min="0" max="20" value="0"><br>
|
||
<input type="radio" name="filterSelect" value="sharp">
|
||
<label for="sharpenRadius">Sharpen radius (pixels):
|
||
<input type="number" id="sharpenRadius" size="5" min="0" max="20" value="0"><br>
|
||
<input type="radio" name="filterSelect" value="edge">
|
||
<label for="sobelRadius">Edge detect radius (pixels):
|
||
<input type="number" id="sobelRadius" size="5" min="0" max="20" value="0">
|
||
</fieldset>
|
||
|
||
<div class="slider-row">
|
||
<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 (127)<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] — uses [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> ≈ <strong><span id="kbytes">0 bytes</span></strong>
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
</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>
|
||
// get page element handles
|
||
|
||
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 cropTop = document.getElementById('cropTop');
|
||
const cropLeft = document.getElementById('cropLeft');
|
||
const cropRight = document.getElementById('cropRight');
|
||
const cropBottom = document.getElementById('cropBottom');
|
||
const filterSelect = document.getElementById('filterSelect');
|
||
const blurRadiusInput = document.getElementById('blurRadius');
|
||
const sobelRadiusInput = document.getElementById('sobelRadius');
|
||
const sharpenRadiusInput = document.getElementById('sharpenRadius');
|
||
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 inputArea = document.getElementById('inputArea');
|
||
const originalCanvas = document.getElementById('originalCanvas');
|
||
const grayscaleCanvas = document.getElementById('grayscaleCanvas');
|
||
const kbytes = document.getElementById('kbytes');
|
||
|
||
// other initializations
|
||
|
||
const originalCtx = originalCanvas.getContext('2d');
|
||
const grayscaleCtx = grayscaleCanvas.getContext('2d');
|
||
|
||
const cropID = [ cropRight, cropTop, cropLeft, cropBottom ]; // counterclockwise from right
|
||
let edgeID = [ 0, 1, 2 ,3 ]; // counterclockwise from right: right, top, left, bottom
|
||
const edgeconfig = [
|
||
// IDs of crop gadgets corresponding to image edges, from right edge counterclockwise,
|
||
// in all combinations of rotations and flips.
|
||
// no flip flipH flipV flipV+H
|
||
/* 0*/ [[0,1,2,3], [2,1,0,3], [0,3,2,1], [2,3,0,1]],
|
||
/* 90*/ [[3,0,1,2], [1,0,3,2], [3,2,1,0], [1,2,3,0]],
|
||
/*180*/ [[2,3,0,1], [0,3,2,1], [2,1,0,3], [0,1,2,3]],
|
||
/*270*/ [[1,2,3,0], [3,2,1,0], [1,0,3,2], [3,0,1,2]]
|
||
];
|
||
let grayscaleMatrix = [];
|
||
let currentImage = new Image();
|
||
let rotation = 0;
|
||
let flipH = false;
|
||
let flipV = false;
|
||
let origDim = { width:0, height:0 };
|
||
let uncropDim = { 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
|
||
|
||
function gaussianKernel1D(radius) {
|
||
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;
|
||
}
|
||
return kernel.map(v => v / sum);
|
||
}
|
||
|
||
function sobelDerivativeKernel(size) {
|
||
const half = Math.floor(size / 2);
|
||
const kernel = [];
|
||
for (let i = -half; i <= half; i++) {
|
||
kernel.push(i);
|
||
}
|
||
const norm = kernel.reduce((acc, val) => acc + Math.abs(val), 0) || 1;
|
||
return kernel.map(v => v / norm);
|
||
}
|
||
|
||
function convolve1DHorizontal(matrix, kernel) {
|
||
const width = matrix[0].length;
|
||
const height = matrix.length;
|
||
const r = Math.floor(kernel.length / 2);
|
||
const result = [];
|
||
let indx, nx;
|
||
for (let y = 0; y < height; y++) {
|
||
result[y] = [];
|
||
for (let x = 0; x < width; x++) {
|
||
let sum = 0;
|
||
for (let k = -r; k <= r; k++) {
|
||
indx = x+k;
|
||
nx = indx<0 ? -indx : (indx>=width ? 2*(width-1)-indx : indx); // reflect edges
|
||
if (nx >= 0 && nx < width) {
|
||
sum += matrix[y][nx] * kernel[k+r];
|
||
}
|
||
}
|
||
result[y][x] = sum;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function convolve1DVertical(matrix, kernel) {
|
||
const width = matrix[0].length;
|
||
const height = matrix.length;
|
||
const r = Math.floor(kernel.length / 2);
|
||
const result = [];
|
||
let indx, ny;
|
||
for (let y = 0; y < height; y++) {
|
||
result[y] = [];
|
||
for (let x = 0; x < width; x++) {
|
||
let sum = 0;
|
||
for (let k = -r; k <= r; k++) {
|
||
indx = y+k;
|
||
ny = indx<0 ? -indx : (indx >= height ? 2*(height-1)-indx : indx); // reflect edges
|
||
if (ny >= 0 && ny < height) {
|
||
sum += matrix[ny][x] * kernel[k+r];
|
||
}
|
||
}
|
||
result[y][x] = sum;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function computeEdgeMagnitude(gx, gy) {
|
||
const height = gx.length;
|
||
const width = gx[0].length;
|
||
const result = [];
|
||
for (let y = 0; y < height; y++) {
|
||
result[y] = [];
|
||
for (let x = 0; x < width; x++) {
|
||
const mag = Math.sqrt(gx[y][x] ** 2 + gy[y][x] ** 2);
|
||
result[y][x] = mag;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function applyGaussianBlur(matrix, blurRadius) {
|
||
if (blurRadius <= 0) return matrix;
|
||
const gKernel = gaussianKernel1D(blurRadius)
|
||
g1 = convolve1DVertical(matrix, gKernel);
|
||
return convolve1DHorizontal(g1, gKernel);
|
||
}
|
||
|
||
function applySharpen(original, radius, k=1.0) {
|
||
if (radius <= 0) return original;
|
||
const height = original.length;
|
||
const width = original[0].length;
|
||
blurred = applyGaussianBlur(original, radius);
|
||
const result = [];
|
||
for (let y = 0; y < height; y++) {
|
||
result[y] = [];
|
||
for (let x = 0; x < width; x++) {
|
||
result[y][x] = original[y][x] + k * (original[y][x] - blurred[y][x]);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function applySobel(matrix, radius) {
|
||
if (radius <= 0) return matrix; // No edge detection
|
||
const sobelSize = 2 * radius + 1;
|
||
const dKernel = sobelDerivativeKernel(sobelSize);
|
||
let gblur = applyGaussianBlur(matrix, radius);
|
||
gx = convolve1DHorizontal(gblur, dKernel);
|
||
gy = convolve1DVertical(gblur, dKernel);
|
||
return computeEdgeMagnitude(gx, gy);
|
||
}
|
||
|
||
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 c = 2.0*contrast; // attempt to balance the sigmoid response to the contrast control
|
||
const sigterm = sigmoid(-c*threshold);
|
||
const adj = contrast>100.0 ? (x<threshold ? 0 : x>threshold ? 1 : threshold) // jump to 100% contrast at max contrast
|
||
: (sigmoid(c*(x-threshold)) - sigterm) / (sigmoid(c*(1.0-threshold)) - sigterm);
|
||
return adj * 255.0;
|
||
}
|
||
|
||
function processImage() {
|
||
if (!currentImage.src) return;
|
||
|
||
origDim.width = currentImage.naturalWidth;
|
||
origDim.height = currentImage.naturalHeight;
|
||
|
||
// display thumbnail original image
|
||
const thumbWidth = 200;
|
||
const thumbHeight = Math.round((origDim.height / origDim.width) * thumbWidth);
|
||
originalCanvas.width = thumbWidth;
|
||
originalCanvas.height = thumbHeight;
|
||
originalCtx.clearRect(0, 0, thumbWidth, thumbHeight);
|
||
originalCtx.drawImage(currentImage, 0, 0, thumbWidth, thumbHeight);
|
||
originalSizeText.textContent = `Original size: ${origDim.width}×${origDim.height}`;
|
||
|
||
// get output image dimensions
|
||
uncropDim.width = origDim.width;
|
||
uncropDim.height = origDim.height;
|
||
const newWidth = parseInt(resizeWidthInput.value);
|
||
if (!isNaN(newWidth) && newWidth > 0) {
|
||
uncropDim.width = newWidth;
|
||
uncropDim.height = Math.round(newWidth * origDim.height / origDim.width);
|
||
}
|
||
|
||
// put original image in a temporary canvas with output dimensions and get image data
|
||
const tempCanvas = document.createElement('canvas');
|
||
tempCanvas.width = uncropDim.width;
|
||
tempCanvas.height = uncropDim.height;
|
||
const tempCtx = tempCanvas.getContext('2d');
|
||
tempCtx.drawImage(currentImage, 0, 0, uncropDim.width, uncropDim.height);
|
||
const imgData = tempCtx.getImageData(0, 0, uncropDim.width, uncropDim.height);
|
||
const data = imgData.data;
|
||
|
||
// convert image data to grayscale
|
||
const brightnessMatrix = [];
|
||
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];
|
||
for (let y = 0; y < uncropDim.height; y++) {
|
||
const row = [];
|
||
for (let x = 0; x < uncropDim.width; x++) {
|
||
const i = (y * uncropDim.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);
|
||
}
|
||
|
||
// apply filter
|
||
const blurRadius = parseInt(blurRadiusInput.value) || 0;
|
||
const sharpenRadius = parseInt(sharpenRadiusInput.value) || 0;
|
||
const sobelRadius = parseInt(sobelRadiusInput.value) || 0;
|
||
let filteredMatrix = [];
|
||
switch(document.querySelector('input[name="filterSelect"]:checked').value) {
|
||
// any of the filters return the original if the radius=0
|
||
case "blur":
|
||
console.log("blur");
|
||
filteredMatrix = applyGaussianBlur(brightnessMatrix, blurRadius);
|
||
break;
|
||
case "sharp":
|
||
console.log("sharp");
|
||
filteredMatrix = applySharpen(brightnessMatrix, sharpenRadius);
|
||
break;
|
||
case "edge":
|
||
console.log("edge");
|
||
filteredMatrix = applySobel(brightnessMatrix, sobelRadius);
|
||
break;
|
||
default:
|
||
console.log("none");
|
||
filteredMatrix = brightnessMatrix;
|
||
}
|
||
|
||
// crop the matrix, gather min and max values in crop area
|
||
const cropMatrix = [];
|
||
let cropx1 = parseInt(cropID[edgeID[2]].value) || 0;
|
||
let cropx2 = parseInt(cropID[edgeID[0]].value) || 0;
|
||
let cropy1 = parseInt(cropID[edgeID[1]].value) || 0;
|
||
let cropy2 = parseInt(cropID[edgeID[3]].value) || 0;
|
||
let min = 32000;
|
||
let max = -32000;
|
||
for (let y=cropy1; y<uncropDim.height-cropy2; y++) {
|
||
const row = [];
|
||
for(let x=cropx1; x<uncropDim.width-cropx2; x++) {
|
||
row.push(filteredMatrix[y][x]);
|
||
min = Math.min(min, filteredMatrix[y][x]);
|
||
max = Math.max(max, filteredMatrix[y][x]);
|
||
}
|
||
cropMatrix.push(row);
|
||
}
|
||
cropDim.width = uncropDim.width - cropx1 - cropx2;
|
||
cropDim.height = uncropDim.height - cropy1 - cropy2;
|
||
|
||
// normalize cropped image brightness to 0-255 range, invert brightness if checkbox is selected
|
||
// adjust contrast if needed
|
||
const range = max - min || 1;
|
||
grayscaleMatrix = [];
|
||
const grayImgData = grayscaleCtx.createImageData(cropDim.width, cropDim.height);
|
||
const grayData = grayImgData.data;
|
||
for (let y = 0; y < cropDim.height; y++) {
|
||
const row = [];
|
||
for (let x = 0; x < cropDim.width; x++) {
|
||
let brightness = cropMatrix[y][x];
|
||
brightness = ((brightness - min) / range) * 255;
|
||
if (contrast>0.0002) // adjust contrast if contrast control > 0
|
||
brightness = contrastAdj(brightness);
|
||
if (invertBrightness)
|
||
brightness = 255 - brightness;
|
||
brightness = Math.max(0, Math.min(255, Math.round(brightness)));
|
||
const i = (y * cropDim.width + x) * 4;
|
||
grayData[i] = grayData[i+1] = grayData[i+2] = brightness;
|
||
grayData[i + 3] = 255;
|
||
row.push(brightness);
|
||
}
|
||
grayscaleMatrix.push(row);
|
||
}
|
||
|
||
// rotate and flip image
|
||
const rotated = (rotation % 180 !== 0);
|
||
const finalWidth = rotated ? cropDim.height : cropDim.width;
|
||
const finalHeight = rotated ? cropDim.width : cropDim.height;
|
||
grayscaleCanvas.width = finalWidth;
|
||
grayscaleCanvas.height = finalHeight;
|
||
|
||
const tempDrawCanvas = document.createElement('canvas');
|
||
tempDrawCanvas.width = cropDim.width;
|
||
tempDrawCanvas.height = cropDim.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, -cropDim.width / 2, -cropDim.height / 2);
|
||
grayscaleCtx.restore();
|
||
|
||
grayscaleSizeText.textContent = `Output size: ${finalWidth}×${finalHeight}`;
|
||
updateKbytes();
|
||
}
|
||
|
||
// image loading functions
|
||
|
||
function resetInputs() { // executed after an image loads
|
||
cropLeft.value="0";
|
||
cropRight.value="0";
|
||
cropTop.value="0";
|
||
cropBottom.value="0";
|
||
rotation = 0;
|
||
flipV = flipH = false;
|
||
resizeWidthInput.value = "100";
|
||
blurRadiusInput.value = "0";
|
||
sobelRadiusInput.value = "0";
|
||
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
|
||
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 () {
|
||
resetInputs();
|
||
processImage();
|
||
};
|
||
currentImage.src = e.target.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
});
|
||
|
||
// user pasted an image from the clipboard into the input area
|
||
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 () {
|
||
resetInputs();
|
||
processImage();
|
||
};
|
||
currentImage.src = e.target.result;
|
||
};
|
||
reader.readAsDataURL(blob);
|
||
}
|
||
}
|
||
});
|
||
|
||
// set up event listeners for all the input gadgets
|
||
|
||
[blurRadiusInput, sobelRadiusInput, sharpenRadiusInput, contrastInput, thresholdInput,
|
||
...document.querySelectorAll('input[name="grayModel"]'),
|
||
...document.querySelectorAll('input[name="filterSelect"]')
|
||
].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', () => {
|
||
if (!currentImage.src) { cropLeft.value="0"; return; }
|
||
const cl = parseInt(cropLeft.value) || 0;
|
||
const cr = parseInt(cropRight.value) || 0;
|
||
const newcl = uncropDim.width - cl - cr < 2 ? uncropDim.width - cr - 2 : cl;
|
||
cropLeft.value = newcl.toString();
|
||
resizeWidthInput.min = newcl + cr + 2;
|
||
processImage();
|
||
});
|
||
cropTop.addEventListener('input', () => {
|
||
if (!currentImage.src) { cropTop.value="0"; return; }
|
||
const ct = parseInt(cropTop.value) || 0;
|
||
const cb = parseInt(cropBottom.value) || 0;
|
||
if(uncropDim.width - ct - cb < 2) cropTop.value = (uncropDim.height - cb - 2).toString();
|
||
processImage();
|
||
});
|
||
cropRight.addEventListener('input', () => {
|
||
if (!currentImage.src) { cropRight.value="0"; return; }
|
||
const cl = parseInt(cropLeft.value) || 0;
|
||
const cr = parseInt(cropRight.value) || 0;
|
||
const newcr = uncropDim.width - cl - cr < 2 ? uncropDim.width - cl - 2 : cr;
|
||
cropRight.value = newcr.toString();
|
||
resizeWidthInput.min = cl + newcr + 2;
|
||
processImage();
|
||
});
|
||
cropBottom.addEventListener('input', () => {
|
||
if (!currentImage.src) { cropBottom.value="0"; return; }
|
||
const ct = parseInt(cropTop.value) || 0;
|
||
const cb = parseInt(cropBottom.value) || 0;
|
||
if(uncropDim.width - ct - cb < 2) cropBottom.value = (uncropDim.height - ct - 2).toString();
|
||
processImage();
|
||
});
|
||
|
||
function updateEdgeID(out="") {
|
||
const fi = (flipH ? 1 : 0) + (flipV ? 2 : 0);
|
||
const ri = Math.round(rotation/90);
|
||
edgeID = edgeconfig[ri][fi];
|
||
if (out.length>0) console.log(out, rotation, flipH, flipV, edgeID);
|
||
}
|
||
|
||
rotateLeftBtn.addEventListener('click', () => {
|
||
if (!currentImage.src) return;
|
||
rotation = (rotation - 90 + 360) % 360;
|
||
const tmp = cropTop.value;
|
||
cropTop.value = cropRight.value;
|
||
cropRight.value = cropBottom.value;
|
||
cropBottom.value = cropLeft.value;
|
||
cropLeft.value = tmp;
|
||
updateEdgeID();
|
||
processImage();
|
||
});
|
||
rotateRightBtn.addEventListener('click', () => {
|
||
if (!currentImage.src) return;
|
||
rotation = (rotation + 90) % 360;
|
||
const tmp = cropTop.value;
|
||
cropTop.value = cropLeft.value;
|
||
cropLeft.value = cropBottom.value;
|
||
cropBottom.value = cropRight.value;
|
||
cropRight.value = tmp;
|
||
updateEdgeID();
|
||
processImage();
|
||
});
|
||
flipHorizontalBtn.addEventListener('click', () => {
|
||
if (!currentImage.src) return;
|
||
flipH = !flipH;
|
||
let tmp = cropRight.value;
|
||
cropRight.value = cropLeft.value;
|
||
cropLeft.value = tmp;
|
||
updateEdgeID();
|
||
processImage();
|
||
});
|
||
flipVerticalBtn.addEventListener('click', () => {
|
||
if (!currentImage.src) return;
|
||
flipV = !flipV;
|
||
let tmp = cropTop.value;
|
||
cropTop.value = cropBottom.value;
|
||
cropBottom.value = tmp;
|
||
updateEdgeID();
|
||
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();
|
||
});
|
||
|
||
const Gbyte = 1073741824.0;
|
||
const Mbyte = 1048576.0;
|
||
const Kbyte = 1024.0;
|
||
// update file size estimate based on normalize type and size of output image
|
||
function updateKbytes() {
|
||
// length of a number for [0,1] range: mostly 6 characters "0.xxx," but occasionally less, using 5.95.
|
||
// length of a number for [0,255] range: assume 0-255 are uniformly distributed, use weighted average of digits plus comma
|
||
const avglen = normalizeToUnitCheckbox.checked ? 5.95 : (10.0+90.0*2.0+156.0*3.0)/256.0+1.0;
|
||
// each row has 6 extra characters " [],\r\n" at most, plus 5 characters after array name and 4 characters at the end
|
||
const estsize = (avglen*cropDim.width + 6.0) * cropDim.height + 9 + arrayName.value.length;
|
||
let unitName = "bytes";
|
||
let unit = 1.0;
|
||
if (estsize > Gbyte) { unit = Gbyte; unitName = "GiB"; }
|
||
else if (estsize > Mbyte) { unit = Mbyte; unitName = "MiB"; }
|
||
else if (estsize > 10.0*Kbyte) { unit = Kbyte; unitName = "KiB"; }
|
||
const sizeOut = (estsize/unit).toFixed(unit==1.0?0:1);
|
||
kbytes.textContent = `${sizeOut} ${unitName}`;
|
||
}
|
||
|
||
normalizeToUnitCheckbox.addEventListener('input', () => {
|
||
updateKbytes();
|
||
});
|
||
|
||
// file output functions
|
||
|
||
// try to use "Save As" file picker,
|
||
// fall back to saving with a default name to browser's downloads directory.
|
||
|
||
downloadButton.addEventListener('click', () => {
|
||
if (grayscaleMatrix.length === 0) return alert("No data to save.");
|
||
const useUnit = normalizeToUnitCheckbox.checked;
|
||
const arrayContent = grayscaleMatrix.map(row => {
|
||
return " [" + row.map(val => useUnit ? parseFloat((val/255.0).toFixed(3)) : val).join(",") + "]";
|
||
}).join(",\n");
|
||
const introcomment = " = [ // " + cropDim.width + "×" + cropDim.height + "\n";
|
||
const sizevar = (arrayName.value.length>0 ? arrayName.value : 'image_array')+"_size = [" + cropDim.width + "," + cropDim.height + "];\n";
|
||
const openscadArray = sizevar + (arrayName.value.length>0 ? arrayName.value : 'image_array') + introcomment + arrayContent + "\n];";
|
||
const blob = new Blob([openscadArray], { type: "text/plain" });
|
||
const dimSuffix = "_"+cropDim.width + "x" + cropDim.height;
|
||
let filename = (arrayName.value.length>0 ? arrayName.value : "image_array") + dimSuffix + '.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>
|