const image = new Image;
const imageSrcs = ["https://upload.wikimedia.org/wikipedia/en/2/24/Lenna.png", "//i.imgur.com/tbRxrWA.jpg"];
var scaleFull = false;
var imageBWA;
var imageBWB;
var imageBWC;
var amountA = -1;
var thresholdA = -1;
var thresholdB = -1;
var cutoffC = -1;
var thresholdC = -1;
start();
//Using stacked global composite operations.
function twoTone(bw, amount, threshold) {
bw.ctx.save();
bw.ctx.globalCompositeOperation = "saturation";
bw.ctx.fillStyle = "#888"; // no saturation
bw.ctx.fillRect(0, 0, bw.width, bw.height);
amount /= 16;
threshold = 255 - threshold;
while (amount-- > 0) {
bw.ctx.globalAlpha = 1;
bw.ctx.globalCompositeOperation = "multiply";
bw.ctx.drawImage(bw, 0, 0);
const a = (threshold / 127);
bw.ctx.globalAlpha = a > 1 ? 1 : a;
bw.ctx.globalCompositeOperation = "lighter";
bw.ctx.drawImage(bw, 0, 0);
if (a > 1) {
bw.ctx.globalAlpha = a - 1 > 1 ? 1 : a - 1;
bw.ctx.drawImage(bw, 0, 0);
bw.ctx.drawImage(bw, 0, 0);
}
}
bw.ctx.restore();
}
// Using per pixel processing simple threshold.
function twoTonePixelP(bw, threshold) {
const imgD = bw.ctx.getImageData(0, 0, bw.width, bw.height);
const d = imgD.data;
var i = 0;
var v;
while (i < d.length) {
v = (d[i++] + d[i++] + d[i]) < (threshold * 3) ? 0 : 255;
i -= 2;
d[i++] = d[i++] = d[i++] = v;
i++;
}
bw.ctx.putImageData(imgD, 0, 0);
}
//Using per pixel processing with cutoff width
function twoTonePixelCutoff(bw, cutoff, threshold) {
if (cutoff === 0) {
twoTonePixelP(bw, threshold);
return;
}
const eCurve = (v, p) => {
var vv;
return (vv = Math.pow(v, 2)) / (vv + Math.pow(1 - v, 2))
}
const imgD = bw.ctx.getImageData(0, 0, bw.width, bw.height);
const d = imgD.data;
var i = 0;
var v;
const mult = 255 / cutoff;
const offset = -(threshold * mult) + 127;
while (i < d.length) {
v = ((d[i++] + d[i++] + d[i]) / 3) * mult + offset;
v = v < 0 ? 0 : v > 255 ? 255 : eCurve(v / 255) * 255;
i -= 2;
d[i++] = d[i++] = d[i++] = v;
i++;
}
bw.ctx.putImageData(imgD, 0, 0);
}
function OtsuMean(image, type) {
// Otsu's method, from: https://en.wikipedia.org/wiki/Otsu%27s_Method#Variant_2
//
// The input argument pixelsNumber is the number of pixels in the given image. The
// input argument histogram is a 256-element histogram of a grayscale image
// different gray-levels.
// This function outputs the threshold for the image.
function otsu(histogram, pixelsNumber) {
var sum = 0, sumB = 0, wB = 0, wF = 0, mB, mF, max = 0, between, threshold = 0;
for (var i = 0; i < 256; i++) {
wB += histogram[i];
if (wB === 0) continue;
wF = pixelsNumber - wB;
if (wF === 0) break;
sumB += i * histogram[i];
mB = sumB / wB;
mF = (sum - sumB) / wF;
between = wB * wF * Math.pow(mB - mF, 2);
if (between > max) {
max = between;
threshold = i;
}
}
return threshold>>1;
}
const imgD = image.ctx.getImageData(0, 0, image.width, image.height);
const d = imgD.data;
var histogram = new Uint16Array(256);
if(type == 2){
for(var i = 0; i < d.length; i += 4) {
histogram[Math.round(d[i]*.2126+d[i+1]*.7152+d[i+2]*.0722)]++;
}
}else{
for(var i = 0; i < d.length; i += 4) {
histogram[Math.round(Math.sqrt(d[i]*d[i]*.2126+d[i+1]*d[i+1]*.7152+d[i+2]*d[i+2]*.0722))]++;
}
}
return otsu(histogram, image.width * image.height);
}
// finds mean via the perceptual 2,7,1 approx rule rule
function calcMean(image, rule = 0){
if(rule == 2 || rule == 3){
return OtsuMean(image, rule);
}
const imgD = image.ctx.getImageData(0, 0, image.width, image.height);
const d = imgD.data;
var i = 0;
var sum = 0;
var count = 0
while (i < d.length) {
if(rule == 0){
sum += d[i++] * 0.2 + d[i++] * 0.7 + d[i++] * 0.1;
count += 1;
}else{
sum += d[i++] + d[i++] + d[i++];
count += 3;
}
i++;
}
return (sum / count) | 0;
}
// creates a canvas copy of an image.
function makeImageEditable(image) {
const c = document.createElement("canvas");
c.width = (image.width / 2) | 0;
c.height = (image.height / 2) | 0;
c.ctx = c.getContext("2d");
c.ctx.drawImage(image, 0, 0, c.width, c.height);
return c;
}
function updateEditableImage(image,editable) {
editable.width = (image.width / (scaleFull ? 1 : 2)) | 0;
editable.height = (image.height / (scaleFull ? 1 : 2)) | 0;
editable.ctx.drawImage(image, 0, 0, editable.width, editable.height);
}
// load test image and when loaded start UI
function start() {
image.crossOrigin = "anonymous";
image.src = imageSrcs[0];
imageStatus.textContent = "Loading image 1";
image.onload = ()=>{
imageBWA = makeImageEditable(image);
imageBWB = makeImageEditable(image);
imageBWC = makeImageEditable(image);
canA.appendChild(imageBWA);
canB.appendChild(imageBWB);
canC.appendChild(imageBWC);
imageStatus.textContent = "Loaded image 1.";
startUI();
}
}
function selectImage(idx){
imageStatus.textContent = "Loading image " + idx;
image.src = imageSrcs[idx];
image.onload = ()=>{
updateEditableImage(image, imageBWA);
updateEditableImage(image, imageBWB);
updateEditableImage(image, imageBWC);
thresholdC = thresholdB = thresholdA = -1; // force update
imageStatus.textContent = "Loaded image " + idx;
}
}
function toggleScale(){
scaleFull = !scaleFull;
imageStatus.textContent = scaleFull ? "Image full scale." : "Image half scale";
updateEditableImage(image, imageBWA);
updateEditableImage(image, imageBWB);
updateEditableImage(image, imageBWC);
thresholdC = thresholdB = thresholdA = -1; // force update
}
function findMean(e){
imageBWB.ctx.drawImage(image, 0, 0, imageBWB.width, imageBWB.height);
var t = inputThresholdB.value = inputThresholdC.value = calcMean(imageBWB,e.target.dataset.method);
imageStatus.textContent = "New threshold calculated " + t + ". Method : "+ e.target.dataset.name;
thresholdB = thresholdC = -1;
};
// start the UI
function startUI() {
imageControl.className = "imageSel";
selImage1Btn.addEventListener("click",(e)=>selectImage(0));
selImage2Btn.addEventListener("click",(e)=>selectImage(1));
togFullsize.addEventListener("click",toggleScale);
findMean1.addEventListener("click",findMean);
findMean2.addEventListener("click",findMean);
findMean3.addEventListener("click",findMean);
// updates top image
function update1() {
if (amountA !== inputAmountA.value || thresholdA !== inputThresholdA.value) {
amountA = inputAmountA.value;
thresholdA = inputThresholdA.value;
inputAmountValueA.textContent = amountA;
inputThresholdValueA.textContent = thresholdA;
imageBWA.ctx.drawImage(image, 0, 0, imageBWA.width, imageBWA.height);
twoTone(imageBWA, amountA, thresholdA);
}
requestAnimationFrame(update1);
}
requestAnimationFrame(update1);
// updates center image
function update2() {
if (thresholdB !== inputThresholdB.value) {
thresholdB = inputThresholdB.value;
inputThresholdValueB.textContent = thresholdB;
imageBWB.ctx.drawImage(image, 0, 0, imageBWB.width, imageBWB.height);
twoTonePixelP(imageBWB, thresholdB);
}
requestAnimationFrame(update2);
}
requestAnimationFrame(update2);
// updates bottom image
function update3() {
if (cutoffC !== inputCutoffC.value || thresholdC !== inputThresholdC.value) {
cutoffC = inputCutoffC.value;
thresholdC = inputThresholdC.value;
inputCutoffValueC.textContent = cutoffC;
inputThresholdValueC.textContent = thresholdC;
imageBWC.ctx.drawImage(image, 0, 0, imageBWC.width, imageBWC.height);
twoTonePixelCutoff(imageBWC, cutoffC, thresholdC);
}
requestAnimationFrame(update3);
}
requestAnimationFrame(update3);
}
.imageIso {
border: 2px solid black;
padding: 5px;
margin: 5px;
font-size : 12px;
}
.imageSel {
border: 2px solid black;
padding: 5px;
margin: 5px;
}
#imageStatus {
margin: 5px;
font-size: 12px;
}
.btn {
margin: 2px;
font-size : 12px;
border: 1px solid black;
background : white;
padding: 5px;
cursor : pointer;
}
.btn:hover {
background : #DDD;
}
body {
font-family: arial;
font-siae: 12px;
}
canvas {
border: 2px solid black;
padding: 5px;
}
.hide {
display: none;
}
<div class="imageSel hide" id="imageControl">
<input class="btn" id="selImage1Btn" type="button" value="Image 1"></input>
<input class="btn" id="selImage2Btn" type="button" value="Image 2"></input>
<input class="btn" id="togFullsize" type="button" value="Toggle fullsize"></input>
<input class="btn" id="findMean1" type="button" value="Mean M1" data-method=0 data-name="perceptual mean approximation" title="Get the image mean to use as threshold value using perceptual mean approximation"></input>
<input class="btn" id="findMean2" type="button" value="Mean M2" data-method=1 data-name="Pixel RGB sum mean" title="Get threshold value using RGB sum mean"></input>
<input class="btn" id="findMean3" type="button" value="Mean Otsu" data-method=2 data-name="Otsu's method" title="Get threshold value using Otsu's method"></input>
<div id="imageStatus"></div>
</div>
<div class="imageIso">
Using per pixel processing simple threshold. Quick in terms of pixel processing but produces a hard boundary at the threshold value.<br>
<div id="canB"></div>
Threshold<input id="inputThresholdB" type="range" min="1" max="255" step="1" value="128"></input><span id="inputThresholdValueB"></span>
</div>
<div class="imageIso">
Using per pixel processing with cutoff width. This softens the cutoff boundary by gray scaling the values at the threshold.<br>
<div id="canC"></div>
Cutoff width<input id="inputCutoffC" type="range" min="0" max="64" step="0.1" value="8"></input><span id="inputCutoffValueC"></span><br> Threshold
<input id="inputThresholdC" type="range" min="1" max="255" step="1" value="128"></input><span id="inputThresholdValueC"></span>
</div>
<div class="imageIso">
<h2>Means not applied to this image</h2>
Using stacked global composite operations. The quickest method and does not require secure pixel access. Though threshold and cutoff are imprecise.<br>
<div id="canA"></div>
Amount<input id="inputAmountA" type="range" min="1" max="100" step="1" value="75"></input><span id="inputAmountValueA"></span><br> Threshold
<input id="inputThresholdA" type="range" min="1" max="255" step="1" value="127"></input><span id="inputThresholdValueA"></span>
</div>