2025-04-23 06:45:39 +00:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="en">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<!--
|
|
|
|
|
|
Standalone web app to convert an image file to an OpenSCAD array, for use with BOSL2 textures.
|
2025-04-27 02:49:19 +00:00
|
|
|
|
Versions 1-5: 22 April 2025 - by Alex Matulich (collaborating with ChatGPT for crop panel CSS, file loading and saving, and Gaussian blur)
|
2025-04-24 05:22:02 +00:00
|
|
|
|
Version 6: 23 April 2025 - added cropping UI
|
2025-04-25 17:54:54 +00:00
|
|
|
|
Version 7: 25 April 2025 - added contrast and threshold sliders
|
2025-04-27 02:49:19 +00:00
|
|
|
|
Version 8: 26 April 2025 - added file size estimate to output section
|
2025-05-21 04:06:56 +00:00
|
|
|
|
Version 9: 20 May 2025 - improved appearance UI, added Sobel edge detection
|
2025-05-23 03:02:50 +00:00
|
|
|
|
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
|
2025-05-30 23:38:01 +00:00
|
|
|
|
Version 12: 30 May 2025 - Made filters mutually exclusive
|
2025-04-23 06:45:39 +00:00
|
|
|
|
-->
|
2025-05-30 23:38:01 +00:00
|
|
|
|
<title>Image to OpenSCAD array, v12</title><!-- REMEMBER TO CHANGE VERSION -->
|
2025-04-23 06:45:39 +00:00
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<style>
|
|
|
|
|
|
body { font-family: sans-serif; padding-left:1em; padding-right:1em;}
|
|
|
|
|
|
h1,h2,h3,h4 { font-family: serif; }
|
2025-04-25 17:54:54 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-04-27 02:49:19 +00:00
|
|
|
|
|
|
|
|
|
|
input[type="range"] {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
2025-04-25 17:54:54 +00:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-23 06:45:39 +00:00
|
|
|
|
.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;
|
2025-04-24 03:47:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-24 05:22:02 +00:00
|
|
|
|
/* cropping control panel stuff */
|
2025-04-24 03:47:54 +00:00
|
|
|
|
|
|
|
|
|
|
.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;
|
2025-04-24 05:22:02 +00:00
|
|
|
|
margin-top: 8px;
|
2025-04-24 03:47:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
.crop-center {
|
|
|
|
|
|
grid-area: center;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 60px;
|
|
|
|
|
|
height: 60px;
|
|
|
|
|
|
border: 2px dashed #ccc;
|
2025-04-24 05:22:02 +00:00
|
|
|
|
background-color: #eee;
|
2025-04-24 03:47:54 +00:00
|
|
|
|
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;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<h1>Convert image to OpenSCAD array</h1>
|
2025-04-25 17:54:54 +00:00
|
|
|
|
<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>
|
2025-04-27 02:49:19 +00:00
|
|
|
|
<p>Keep the output image width small! A large size results in a huge output file when converting an image to text data.</p>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
<hr>
|
|
|
|
|
|
<div id="content">
|
|
|
|
|
|
<div class="uiContainer" id="inputArea" tabindex="0">
|
2025-04-25 17:54:54 +00:00
|
|
|
|
<fieldset>
|
|
|
|
|
|
<legend>Select an image</legend>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
<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>
|
2025-04-25 17:54:54 +00:00
|
|
|
|
</fieldset>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
<!-- Original image canvas -->
|
|
|
|
|
|
<div class="canvasWrapper">
|
|
|
|
|
|
<p id="originalSize"></p>
|
|
|
|
|
|
<canvas id="originalCanvas" width="200"></canvas>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="uiContainer" id="outputArea">
|
|
|
|
|
|
<div>
|
2025-04-25 17:54:54 +00:00
|
|
|
|
<fieldset>
|
|
|
|
|
|
<legend>Transformations</legend>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
<label for="resizeWidth">Rescale original width (px):</label>
|
2025-04-24 03:47:54 +00:00
|
|
|
|
<input type="number" id="resizeWidth" size="5" min="1" max="9000" value="100"><br>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-04-24 05:22:02 +00:00
|
|
|
|
<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>
|
2025-04-25 17:54:54 +00:00
|
|
|
|
</fieldset>
|
2025-04-24 03:47:54 +00:00
|
|
|
|
|
2025-04-25 17:54:54 +00:00
|
|
|
|
<fieldset>
|
|
|
|
|
|
<legend>Appearance</legend>
|
2025-05-21 04:06:56 +00:00
|
|
|
|
<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>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
<input type="radio" name="grayModel" value="ntsc" checked><label for "grayModel" class="tooltip"> NTSC grayscale formula
|
2025-05-17 19:59:19 +00:00
|
|
|
|
<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>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
|
|
|
|
|
|
<div style="margin-top:8px;">
|
|
|
|
|
|
<label><input type="checkbox" id="invertBrightness"> Invert brightness</label>
|
|
|
|
|
|
</div>
|
2025-05-23 03:02:50 +00:00
|
|
|
|
<fieldset style="margin:8px 0;">
|
2025-05-30 23:38:01 +00:00
|
|
|
|
<legend style="font-size:medium;">Filter</legend>
|
|
|
|
|
|
<input type="radio" name="filterSelect" value="blur" checked>
|
2025-04-25 17:54:54 +00:00
|
|
|
|
<label for="blurRadius">Gaussian blur radius (pixels):</label>
|
2025-05-21 04:06:56 +00:00
|
|
|
|
<input type="number" id="blurRadius" size="5" min="0" max="20" value="0"><br>
|
2025-05-30 23:38:01 +00:00
|
|
|
|
<input type="radio" name="filterSelect" value="sharp">
|
2025-05-23 03:02:50 +00:00
|
|
|
|
<label for="sharpenRadius">Sharpen radius (pixels):
|
|
|
|
|
|
<input type="number" id="sharpenRadius" size="5" min="0" max="20" value="0"><br>
|
2025-05-30 23:38:01 +00:00
|
|
|
|
<input type="radio" name="filterSelect" value="edge">
|
2025-05-23 03:02:50 +00:00
|
|
|
|
<label for="sobelRadius">Edge detect radius (pixels):
|
2025-05-21 04:06:56 +00:00
|
|
|
|
<input type="number" id="sobelRadius" size="5" min="0" max="20" value="0">
|
2025-05-23 03:02:50 +00:00
|
|
|
|
</fieldset>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
|
2025-04-25 17:54:54 +00:00
|
|
|
|
<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
|
2025-04-27 02:49:19 +00:00
|
|
|
|
<span class="tooltiptext">Level between black (-128) and white (127)<br>around which to adjust contrast.</span></label>
|
2025-04-25 17:54:54 +00:00
|
|
|
|
<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>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
<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;">
|
2025-04-27 02:49:19 +00:00
|
|
|
|
<button id="downloadButton">Save as OpenSCAD array</button> ≈ <strong><span id="kbytes">0 bytes</span></strong>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-04-25 17:54:54 +00:00
|
|
|
|
</fieldset>
|
2025-04-23 06:45:39 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Grayscale output image canvas -->
|
|
|
|
|
|
<div class="canvasWrapper">
|
|
|
|
|
|
<p id="grayscaleSize"></p>
|
|
|
|
|
|
<div id="outcontainer">
|
|
|
|
|
|
<canvas id="grayscaleCanvas"></canvas>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<hr>
|
2025-04-24 05:22:02 +00:00
|
|
|
|
|
2025-04-23 06:45:39 +00:00
|
|
|
|
<script>
|
2025-04-24 05:22:02 +00:00
|
|
|
|
// get page element handles
|
|
|
|
|
|
|
2025-04-23 06:45:39 +00:00
|
|
|
|
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');
|
2025-04-24 05:22:02 +00:00
|
|
|
|
const cropTop = document.getElementById('cropTop');
|
|
|
|
|
|
const cropLeft = document.getElementById('cropLeft');
|
|
|
|
|
|
const cropRight = document.getElementById('cropRight');
|
|
|
|
|
|
const cropBottom = document.getElementById('cropBottom');
|
2025-05-30 23:38:01 +00:00
|
|
|
|
const filterSelect = document.getElementById('filterSelect');
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const blurRadiusInput = document.getElementById('blurRadius');
|
2025-05-21 04:06:56 +00:00
|
|
|
|
const sobelRadiusInput = document.getElementById('sobelRadius');
|
2025-05-23 03:02:50 +00:00
|
|
|
|
const sharpenRadiusInput = document.getElementById('sharpenRadius');
|
2025-04-25 17:54:54 +00:00
|
|
|
|
const contrastInput = document.getElementById('contrast');
|
|
|
|
|
|
const contrastValue = document.getElementById('contrastValue');
|
|
|
|
|
|
const thresholdInput = document.getElementById('threshold');
|
|
|
|
|
|
const thresholdValue = document.getElementById('thresholdValue');
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const arrayName = document.getElementById('arrayName');
|
|
|
|
|
|
const inputArea = document.getElementById('inputArea');
|
|
|
|
|
|
const originalCanvas = document.getElementById('originalCanvas');
|
|
|
|
|
|
const grayscaleCanvas = document.getElementById('grayscaleCanvas');
|
2025-04-27 02:49:19 +00:00
|
|
|
|
const kbytes = document.getElementById('kbytes');
|
2025-04-23 06:45:39 +00:00
|
|
|
|
|
2025-04-24 05:22:02 +00:00
|
|
|
|
// other initializations
|
|
|
|
|
|
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const originalCtx = originalCanvas.getContext('2d');
|
|
|
|
|
|
const grayscaleCtx = grayscaleCanvas.getContext('2d');
|
|
|
|
|
|
|
2025-04-24 05:22:02 +00:00
|
|
|
|
const cropID = [ cropRight, cropTop, cropLeft, cropBottom ]; // counterclockwise from right
|
|
|
|
|
|
let edgeID = [ 0, 1, 2 ,3 ]; // counterclockwise from right: right, top, left, bottom
|
|
|
|
|
|
const edgeconfig = [
|
2025-04-25 17:54:54 +00:00
|
|
|
|
// IDs of crop gadgets corresponding to image edges, from right edge counterclockwise,
|
|
|
|
|
|
// in all combinations of rotations and flips.
|
2025-04-24 05:22:02 +00:00
|
|
|
|
// 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]]
|
|
|
|
|
|
];
|
2025-04-23 06:45:39 +00:00
|
|
|
|
let grayscaleMatrix = [];
|
|
|
|
|
|
let currentImage = new Image();
|
|
|
|
|
|
let rotation = 0;
|
|
|
|
|
|
let flipH = false;
|
|
|
|
|
|
let flipV = false;
|
2025-04-24 05:22:02 +00:00
|
|
|
|
let origDim = { width:0, height:0 };
|
|
|
|
|
|
let uncropDim = { width:0, height:0 };
|
|
|
|
|
|
let cropDim = { width:0, height:0 };
|
2025-04-25 17:54:54 +00:00
|
|
|
|
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
|
2025-04-24 05:22:02 +00:00
|
|
|
|
|
|
|
|
|
|
// image processing functions
|
2025-04-23 06:45:39 +00:00
|
|
|
|
|
2025-05-21 04:06:56 +00:00
|
|
|
|
function gaussianKernel1D(radius) {
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const sigma = radius > 0 ? radius / 3 : 1;
|
|
|
|
|
|
const kernel = [];
|
|
|
|
|
|
let sum = 0;
|
2025-05-21 04:06:56 +00:00
|
|
|
|
for (let i = -radius; i <= radius; i++) {
|
|
|
|
|
|
const value = Math.exp(- (i * i) / (2 * sigma * sigma));
|
2025-04-23 06:45:39 +00:00
|
|
|
|
kernel.push(value);
|
|
|
|
|
|
sum += value;
|
|
|
|
|
|
}
|
2025-05-21 04:06:56 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-04-23 06:45:39 +00:00
|
|
|
|
|
2025-05-23 03:02:50 +00:00
|
|
|
|
function convolve1DHorizontal(matrix, kernel) {
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const width = matrix[0].length;
|
|
|
|
|
|
const height = matrix.length;
|
2025-05-21 04:06:56 +00:00
|
|
|
|
const r = Math.floor(kernel.length / 2);
|
|
|
|
|
|
const result = [];
|
2025-05-23 03:02:50 +00:00
|
|
|
|
let indx, nx;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
for (let y = 0; y < height; y++) {
|
2025-05-21 04:06:56 +00:00
|
|
|
|
result[y] = [];
|
2025-04-23 06:45:39 +00:00
|
|
|
|
for (let x = 0; x < width; x++) {
|
2025-05-21 04:06:56 +00:00
|
|
|
|
let sum = 0;
|
|
|
|
|
|
for (let k = -r; k <= r; k++) {
|
2025-05-23 03:02:50 +00:00
|
|
|
|
indx = x+k;
|
|
|
|
|
|
nx = indx<0 ? -indx : (indx>=width ? 2*(width-1)-indx : indx); // reflect edges
|
2025-04-23 06:45:39 +00:00
|
|
|
|
if (nx >= 0 && nx < width) {
|
2025-05-21 04:06:56 +00:00
|
|
|
|
sum += matrix[y][nx] * kernel[k+r];
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-23 03:02:50 +00:00
|
|
|
|
result[y][x] = sum;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-21 04:06:56 +00:00
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-23 03:02:50 +00:00
|
|
|
|
function convolve1DVertical(matrix, kernel) {
|
2025-05-21 04:06:56 +00:00
|
|
|
|
const width = matrix[0].length;
|
|
|
|
|
|
const height = matrix.length;
|
|
|
|
|
|
const r = Math.floor(kernel.length / 2);
|
|
|
|
|
|
const result = [];
|
2025-05-23 03:02:50 +00:00
|
|
|
|
let indx, ny;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
for (let y = 0; y < height; y++) {
|
2025-05-21 04:06:56 +00:00
|
|
|
|
result[y] = [];
|
2025-04-23 06:45:39 +00:00
|
|
|
|
for (let x = 0; x < width; x++) {
|
2025-05-21 04:06:56 +00:00
|
|
|
|
let sum = 0;
|
|
|
|
|
|
for (let k = -r; k <= r; k++) {
|
2025-05-23 03:02:50 +00:00
|
|
|
|
indx = y+k;
|
|
|
|
|
|
ny = indx<0 ? -indx : (indx >= height ? 2*(height-1)-indx : indx); // reflect edges
|
2025-04-23 06:45:39 +00:00
|
|
|
|
if (ny >= 0 && ny < height) {
|
2025-05-21 04:06:56 +00:00
|
|
|
|
sum += matrix[ny][x] * kernel[k+r];
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-23 03:02:50 +00:00
|
|
|
|
result[y][x] = sum;
|
2025-05-21 04:06:56 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-21 04:06:56 +00:00
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyGaussianBlur(matrix, blurRadius) {
|
2025-05-23 03:02:50 +00:00
|
|
|
|
if (blurRadius <= 0) return matrix;
|
2025-05-21 04:06:56 +00:00
|
|
|
|
const gKernel = gaussianKernel1D(blurRadius)
|
|
|
|
|
|
g1 = convolve1DVertical(matrix, gKernel);
|
|
|
|
|
|
return convolve1DHorizontal(g1, gKernel);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-30 23:38:01 +00:00
|
|
|
|
function applySharpen(original, radius, k=1.0) {
|
|
|
|
|
|
if (radius <= 0) return original;
|
2025-05-23 03:02:50 +00:00
|
|
|
|
const height = original.length;
|
|
|
|
|
|
const width = original[0].length;
|
2025-05-30 23:38:01 +00:00
|
|
|
|
blurred = applyGaussianBlur(original, radius);
|
2025-05-23 03:02:50 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-30 23:38:01 +00:00
|
|
|
|
function applySobel(matrix, radius) {
|
|
|
|
|
|
if (radius <= 0) return matrix; // No edge detection
|
|
|
|
|
|
const sobelSize = 2 * radius + 1;
|
2025-05-21 04:06:56 +00:00
|
|
|
|
const dKernel = sobelDerivativeKernel(sobelSize);
|
2025-05-30 23:38:01 +00:00
|
|
|
|
let gblur = applyGaussianBlur(matrix, radius);
|
2025-05-23 03:02:50 +00:00
|
|
|
|
gx = convolve1DHorizontal(gblur, dKernel);
|
|
|
|
|
|
gy = convolve1DVertical(gblur, dKernel);
|
2025-05-21 04:06:56 +00:00
|
|
|
|
return computeEdgeMagnitude(gx, gy);
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-25 17:54:54 +00:00
|
|
|
|
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
|
2025-05-21 04:06:56 +00:00
|
|
|
|
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;
|
2025-04-25 17:54:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-23 06:45:39 +00:00
|
|
|
|
function processImage() {
|
|
|
|
|
|
if (!currentImage.src) return;
|
|
|
|
|
|
|
2025-04-24 03:47:54 +00:00
|
|
|
|
origDim.width = currentImage.naturalWidth;
|
|
|
|
|
|
origDim.height = currentImage.naturalHeight;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
|
2025-04-24 03:47:54 +00:00
|
|
|
|
// display thumbnail original image
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const thumbWidth = 200;
|
2025-04-24 03:47:54 +00:00
|
|
|
|
const thumbHeight = Math.round((origDim.height / origDim.width) * thumbWidth);
|
2025-04-23 06:45:39 +00:00
|
|
|
|
originalCanvas.width = thumbWidth;
|
|
|
|
|
|
originalCanvas.height = thumbHeight;
|
|
|
|
|
|
originalCtx.clearRect(0, 0, thumbWidth, thumbHeight);
|
|
|
|
|
|
originalCtx.drawImage(currentImage, 0, 0, thumbWidth, thumbHeight);
|
2025-04-24 03:47:54 +00:00
|
|
|
|
originalSizeText.textContent = `Original size: ${origDim.width}×${origDim.height}`;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
|
2025-04-24 03:47:54 +00:00
|
|
|
|
// get output image dimensions
|
2025-04-24 05:22:02 +00:00
|
|
|
|
uncropDim.width = origDim.width;
|
|
|
|
|
|
uncropDim.height = origDim.height;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const newWidth = parseInt(resizeWidthInput.value);
|
|
|
|
|
|
if (!isNaN(newWidth) && newWidth > 0) {
|
2025-04-24 03:47:54 +00:00
|
|
|
|
uncropDim.width = newWidth;
|
|
|
|
|
|
uncropDim.height = Math.round(newWidth * origDim.height / origDim.width);
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-24 03:47:54 +00:00
|
|
|
|
// put original image in a temporary canvas with output dimensions and get image data
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const tempCanvas = document.createElement('canvas');
|
2025-04-24 03:47:54 +00:00
|
|
|
|
tempCanvas.width = uncropDim.width;
|
|
|
|
|
|
tempCanvas.height = uncropDim.height;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const tempCtx = tempCanvas.getContext('2d');
|
2025-04-24 03:47:54 +00:00
|
|
|
|
tempCtx.drawImage(currentImage, 0, 0, uncropDim.width, uncropDim.height);
|
|
|
|
|
|
const imgData = tempCtx.getImageData(0, 0, uncropDim.width, uncropDim.height);
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const data = imgData.data;
|
|
|
|
|
|
|
2025-04-24 03:47:54 +00:00
|
|
|
|
// convert image data to grayscale
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const brightnessMatrix = [];
|
2025-04-25 17:54:54 +00:00
|
|
|
|
const model = document.querySelector('input[name="grayModel"]:checked').value;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const weights = model === 'linear' ? [0.2126, 0.7152, 0.0722] : [0.299, 0.587, 0.114];
|
2025-04-24 03:47:54 +00:00
|
|
|
|
for (let y = 0; y < uncropDim.height; y++) {
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const row = [];
|
2025-04-24 03:47:54 +00:00
|
|
|
|
for (let x = 0; x < uncropDim.width; x++) {
|
|
|
|
|
|
const i = (y * uncropDim.width + x) * 4;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-30 23:38:01 +00:00
|
|
|
|
// apply filter
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const blurRadius = parseInt(blurRadiusInput.value) || 0;
|
2025-05-23 03:02:50 +00:00
|
|
|
|
const sharpenRadius = parseInt(sharpenRadiusInput.value) || 0;
|
2025-05-21 04:06:56 +00:00
|
|
|
|
const sobelRadius = parseInt(sobelRadiusInput.value) || 0;
|
2025-05-30 23:38:01 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-05-21 04:06:56 +00:00
|
|
|
|
|
|
|
|
|
|
// crop the matrix, gather min and max values in crop area
|
2025-04-24 05:22:02 +00:00
|
|
|
|
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;
|
2025-05-23 03:02:50 +00:00
|
|
|
|
let min = 32000;
|
|
|
|
|
|
let max = -32000;
|
2025-04-24 05:22:02 +00:00
|
|
|
|
for (let y=cropy1; y<uncropDim.height-cropy2; y++) {
|
2025-04-24 03:47:54 +00:00
|
|
|
|
const row = [];
|
|
|
|
|
|
for(let x=cropx1; x<uncropDim.width-cropx2; x++) {
|
2025-05-23 03:02:50 +00:00
|
|
|
|
row.push(filteredMatrix[y][x]);
|
|
|
|
|
|
min = Math.min(min, filteredMatrix[y][x]);
|
|
|
|
|
|
max = Math.max(max, filteredMatrix[y][x]);
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}
|
2025-04-24 05:22:02 +00:00
|
|
|
|
cropMatrix.push(row);
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}
|
2025-04-24 05:22:02 +00:00
|
|
|
|
cropDim.width = uncropDim.width - cropx1 - cropx2;
|
|
|
|
|
|
cropDim.height = uncropDim.height - cropy1 - cropy2;
|
2025-04-24 03:47:54 +00:00
|
|
|
|
|
|
|
|
|
|
// normalize cropped image brightness to 0-255 range, invert brightness if checkbox is selected
|
2025-04-25 17:54:54 +00:00
|
|
|
|
// adjust contrast if needed
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const range = max - min || 1;
|
|
|
|
|
|
grayscaleMatrix = [];
|
2025-04-24 03:47:54 +00:00
|
|
|
|
const grayImgData = grayscaleCtx.createImageData(cropDim.width, cropDim.height);
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const grayData = grayImgData.data;
|
2025-04-24 03:47:54 +00:00
|
|
|
|
for (let y = 0; y < cropDim.height; y++) {
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const row = [];
|
2025-04-24 03:47:54 +00:00
|
|
|
|
for (let x = 0; x < cropDim.width; x++) {
|
|
|
|
|
|
let brightness = cropMatrix[y][x];
|
2025-04-23 06:45:39 +00:00
|
|
|
|
brightness = ((brightness - min) / range) * 255;
|
2025-04-27 02:49:19 +00:00
|
|
|
|
if (contrast>0.0002) // adjust contrast if contrast control > 0
|
2025-04-25 17:54:54 +00:00
|
|
|
|
brightness = contrastAdj(brightness);
|
|
|
|
|
|
if (invertBrightness)
|
2025-04-23 06:45:39 +00:00
|
|
|
|
brightness = 255 - brightness;
|
2025-04-25 17:54:54 +00:00
|
|
|
|
brightness = Math.max(0, Math.min(255, Math.round(brightness)));
|
2025-04-24 03:47:54 +00:00
|
|
|
|
const i = (y * cropDim.width + x) * 4;
|
|
|
|
|
|
grayData[i] = grayData[i+1] = grayData[i+2] = brightness;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
grayData[i + 3] = 255;
|
|
|
|
|
|
row.push(brightness);
|
|
|
|
|
|
}
|
|
|
|
|
|
grayscaleMatrix.push(row);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-24 03:47:54 +00:00
|
|
|
|
// rotate and flip image
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const rotated = (rotation % 180 !== 0);
|
2025-04-24 03:47:54 +00:00
|
|
|
|
const finalWidth = rotated ? cropDim.height : cropDim.width;
|
|
|
|
|
|
const finalHeight = rotated ? cropDim.width : cropDim.height;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
grayscaleCanvas.width = finalWidth;
|
|
|
|
|
|
grayscaleCanvas.height = finalHeight;
|
|
|
|
|
|
|
|
|
|
|
|
const tempDrawCanvas = document.createElement('canvas');
|
2025-04-24 03:47:54 +00:00
|
|
|
|
tempDrawCanvas.width = cropDim.width;
|
|
|
|
|
|
tempDrawCanvas.height = cropDim.height;
|
2025-04-23 06:45:39 +00:00
|
|
|
|
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);
|
2025-04-24 03:47:54 +00:00
|
|
|
|
grayscaleCtx.drawImage(tempDrawCanvas, -cropDim.width / 2, -cropDim.height / 2);
|
2025-04-23 06:45:39 +00:00
|
|
|
|
grayscaleCtx.restore();
|
|
|
|
|
|
|
|
|
|
|
|
grayscaleSizeText.textContent = `Output size: ${finalWidth}×${finalHeight}`;
|
2025-04-27 02:49:19 +00:00
|
|
|
|
updateKbytes();
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-27 02:49:19 +00:00
|
|
|
|
// image loading functions
|
2025-04-24 05:22:02 +00:00
|
|
|
|
|
|
|
|
|
|
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";
|
2025-05-21 04:06:56 +00:00
|
|
|
|
sobelRadiusInput.value = "0";
|
2025-04-25 17:54:54 +00:00
|
|
|
|
invertBrightnessCheckbox.checked = invertBrightness = false;
|
|
|
|
|
|
contrastInput.value = contrastValue.textContent = "0";
|
|
|
|
|
|
contrast = 0.0001;
|
|
|
|
|
|
thresholdInput.value = thresholdValue.textContent = "0";
|
|
|
|
|
|
threshold = 128.0/255.0;
|
2025-04-24 05:22:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// user pressed button to load image from disk
|
2025-04-23 06:45:39 +00:00
|
|
|
|
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 () {
|
2025-04-24 05:22:02 +00:00
|
|
|
|
resetInputs();
|
2025-04-23 06:45:39 +00:00
|
|
|
|
processImage();
|
|
|
|
|
|
};
|
|
|
|
|
|
currentImage.src = e.target.result;
|
|
|
|
|
|
};
|
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-04-24 05:22:02 +00:00
|
|
|
|
// user pasted an image from the clipboard into the input area
|
2025-04-23 06:45:39 +00:00
|
|
|
|
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 () {
|
2025-04-25 17:54:54 +00:00
|
|
|
|
resetInputs();
|
2025-04-23 06:45:39 +00:00
|
|
|
|
processImage();
|
|
|
|
|
|
};
|
|
|
|
|
|
currentImage.src = e.target.result;
|
|
|
|
|
|
};
|
|
|
|
|
|
reader.readAsDataURL(blob);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-04-25 17:54:54 +00:00
|
|
|
|
// set up event listeners for all the input gadgets
|
2025-04-24 05:22:02 +00:00
|
|
|
|
|
2025-05-23 03:02:50 +00:00
|
|
|
|
[blurRadiusInput, sobelRadiusInput, sharpenRadiusInput, contrastInput, thresholdInput,
|
2025-05-30 23:38:01 +00:00
|
|
|
|
...document.querySelectorAll('input[name="grayModel"]'),
|
|
|
|
|
|
...document.querySelectorAll('input[name="filterSelect"]')
|
|
|
|
|
|
].forEach(el => el.addEventListener('input', processImage)
|
|
|
|
|
|
);
|
2025-04-24 05:22:02 +00:00
|
|
|
|
|
2025-04-25 17:54:54 +00:00
|
|
|
|
resizeWidthInput.addEventListener('input', function () {
|
|
|
|
|
|
let min = parseInt(this.min);
|
|
|
|
|
|
if (parseInt(this.value) < min) this.value = min;
|
|
|
|
|
|
processImage();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-04-24 05:22:02 +00:00
|
|
|
|
cropLeft.addEventListener('input', () => {
|
|
|
|
|
|
if (!currentImage.src) { cropLeft.value="0"; return; }
|
|
|
|
|
|
const cl = parseInt(cropLeft.value) || 0;
|
|
|
|
|
|
const cr = parseInt(cropRight.value) || 0;
|
2025-04-25 17:54:54 +00:00
|
|
|
|
const newcl = uncropDim.width - cl - cr < 2 ? uncropDim.width - cr - 2 : cl;
|
|
|
|
|
|
cropLeft.value = newcl.toString();
|
|
|
|
|
|
resizeWidthInput.min = newcl + cr + 2;
|
2025-04-24 05:22:02 +00:00
|
|
|
|
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;
|
2025-04-25 17:54:54 +00:00
|
|
|
|
const newcr = uncropDim.width - cl - cr < 2 ? uncropDim.width - cl - 2 : cr;
|
|
|
|
|
|
cropRight.value = newcr.toString();
|
|
|
|
|
|
resizeWidthInput.min = cl + newcr + 2;
|
2025-04-24 05:22:02 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-04-24 03:47:54 +00:00
|
|
|
|
|
|
|
|
|
|
rotateLeftBtn.addEventListener('click', () => {
|
2025-04-24 05:22:02 +00:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
2025-04-24 03:47:54 +00:00
|
|
|
|
rotateRightBtn.addEventListener('click', () => {
|
2025-04-24 05:22:02 +00:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
2025-04-24 03:47:54 +00:00
|
|
|
|
flipHorizontalBtn.addEventListener('click', () => {
|
2025-04-24 05:22:02 +00:00
|
|
|
|
if (!currentImage.src) return;
|
|
|
|
|
|
flipH = !flipH;
|
|
|
|
|
|
let tmp = cropRight.value;
|
|
|
|
|
|
cropRight.value = cropLeft.value;
|
|
|
|
|
|
cropLeft.value = tmp;
|
|
|
|
|
|
updateEdgeID();
|
|
|
|
|
|
processImage();
|
|
|
|
|
|
});
|
2025-04-24 03:47:54 +00:00
|
|
|
|
flipVerticalBtn.addEventListener('click', () => {
|
2025-04-24 05:22:02 +00:00
|
|
|
|
if (!currentImage.src) return;
|
|
|
|
|
|
flipV = !flipV;
|
|
|
|
|
|
let tmp = cropTop.value;
|
|
|
|
|
|
cropTop.value = cropBottom.value;
|
|
|
|
|
|
cropBottom.value = tmp;
|
|
|
|
|
|
updateEdgeID();
|
|
|
|
|
|
processImage();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-04-25 17:54:54 +00:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-05-21 04:06:56 +00:00
|
|
|
|
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
|
2025-04-27 02:49:19 +00:00
|
|
|
|
function updateKbytes() {
|
2025-05-21 04:06:56 +00:00
|
|
|
|
// 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
|
2025-04-28 06:31:00 +00:00
|
|
|
|
const avglen = normalizeToUnitCheckbox.checked ? 5.95 : (10.0+90.0*2.0+156.0*3.0)/256.0+1.0;
|
2025-05-21 04:06:56 +00:00
|
|
|
|
// each row has 6 extra characters " [],\r\n" at most, plus 5 characters after array name and 4 characters at the end
|
2025-04-27 02:49:19 +00:00
|
|
|
|
const estsize = (avglen*cropDim.width + 6.0) * cropDim.height + 9 + arrayName.value.length;
|
2025-05-21 04:06:56 +00:00
|
|
|
|
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"; }
|
2025-04-27 02:49:19 +00:00
|
|
|
|
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,
|
2025-04-25 17:54:54 +00:00
|
|
|
|
// fall back to saving with a default name to browser's downloads directory.
|
2025-04-23 06:45:39 +00:00
|
|
|
|
|
|
|
|
|
|
downloadButton.addEventListener('click', () => {
|
2025-04-24 03:47:54 +00:00
|
|
|
|
if (grayscaleMatrix.length === 0) return alert("No data to save.");
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const useUnit = normalizeToUnitCheckbox.checked;
|
|
|
|
|
|
const arrayContent = grayscaleMatrix.map(row => {
|
2025-04-28 06:31:00 +00:00
|
|
|
|
return " [" + row.map(val => useUnit ? parseFloat((val/255.0).toFixed(3)) : val).join(",") + "]";
|
2025-04-23 06:45:39 +00:00
|
|
|
|
}).join(",\n");
|
2025-05-21 04:06:56 +00:00
|
|
|
|
const introcomment = " = [ // " + cropDim.width + "×" + cropDim.height + "\n";
|
2025-05-21 20:48:26 +00:00
|
|
|
|
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];";
|
2025-04-23 06:45:39 +00:00
|
|
|
|
const blob = new Blob([openscadArray], { type: "text/plain" });
|
2025-05-21 20:48:26 +00:00
|
|
|
|
const dimSuffix = "_"+cropDim.width + "x" + cropDim.height;
|
2025-05-17 00:22:22 +00:00
|
|
|
|
let filename = (arrayName.value.length>0 ? arrayName.value : "image_array") + dimSuffix + '.scad';
|
2025-04-23 06:45:39 +00:00
|
|
|
|
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>
|