JavaScriptで画像処理(ハーフトーン処理)

以前個人的に作成していた画像処理ツールを最近弄り直した。
リファクタリングや最新のES構文への対応(できる範囲で)などを行ったついでに誤差拡散法の追加とかも行った。
良い機会なので、ハーフトーン処理周りを記事にまとめることにした。

# ハーフトーン処理
ハーフトーン処理(ハーフトーニング)と呼ばれる二値化処理がある。
一般的に画像処理で「二値化」というと人間が見るためのものではなく、
コンピュータの認識処理用の下処理となる閾値処理を指すことが多いと思う。

ハーフトーニングは同じ「二値化」と括られるがそのような処理とは異なり、
人間が見るための画像を作成するもので、印刷や画像の減色に使われている。

色数はグレースケール画像に単純に適用すると2色になり、
カラー画像のRGBの各チャネルに単純に適用すると2*2*2の8色になる。

## ディザ法

閾値配列に従って二値化を施すことで階調を表現する方法。

閾値配列としてはBayer型のものがよく知られている。

縦横を4倍にした画像で処理した場合を考えると、
そこにある画素の値が明るくなればなるほど、
Bayer配列の0から15の16段階の各値の位置に対応する画素が点灯していく様子がイメージできる。
つまり、17(16+1)段階の階調になるように信号処理で言うD/A変換的なことをしているのが分かると思う。
※ただし、今回はCanvas読み込まれた画像に対して、等倍で掛ける処理になっている。

ディザ法では処理後の画像にBayer配列のパターンが見えるので、癖のある画像になる。

```javascript
  const dither1CH = function (u8array, width, height) {
    const bayer = [
      0, 8, 2, 10,
      12, 4, 14, 6,
      3, 11, 1, 9,
      15, 7, 13, 5
    ];
    const bayer2 = new Uint8Array(bayer.map(x => x * 16 + 8));
    let outputData = new Uint8Array(width * height);
    for (let i = 0; i < height; i += 4) {
      for (let j = 0; j < width; j += 4) {
        for (let dy = 0; dy < 4; ++dy) {
          for (let dx = 0; dx < 4; ++dx) {
            const value = u8array[(i + dy) * width + (j + dx)];

            if (value >= bayer2[dy * 4 + dx]) {
              outputData[(i + dy) * width + (j + dx)] = 0xff;
            } else {
              outputData[(i + dy) * width + (j + dx)] = 0x00;
            }
          }
        }
      }
    }
    return outputData;
  }
```

ディザ法ではパターンの痕跡が画像に風味を生む。
処理例の画像を見ると昔のゲーム機っぽい風味があるかも。

画像を制作物などに使用する場面でこの独特の風味を使うのもアリかもしれない。

ディザ法(グレースケール)
![dither_gray.png](https://qiita-image-store.s3.amazonaws.com/0/28988/e707e8f6-ae2a-9283-73ab-a7cbdeb489d5.png)
ディザ法(カラー)
![dither_color.png](https://qiita-image-store.s3.amazonaws.com/0/28988/e5c1b66d-b727-3f6b-d74d-107094a37b04.png)



## 誤差拡散法


これは画像を左上から舐めていって、
切り上げ・切り捨てで生じた誤差をこれから処理するところに逃してやるというものだ。

以下の図の通り、斜め方向と縦横方向での逃がす比重は異なっている。
※逃がし方にはいくつかバリエーションがあるが、今回は参考にしたページ [^1] の分配の比重に合わせた

|→|→|→|
|:--:|:--:|:--:|
|→|X|5/16|
|3/16|5/16|3/16|

誤差の逃し方的に、右方向と下方向1px領域を広げたほうがいいようにも見えるが、
誤差は高々126以下で比重を掛けても0になるため、拡大しなくても問題ないようだ。

「元の画素値+誤差」はマイナスや256以上になることもあるので、`Int16Array`を使用している。


```javascript
  const errorDiffusion1CH = function (u8array, width, height) {
    let errorDiffusionBuffer = new Int16Array(width * height); // 誤差拡散法で元画像+処理誤差を一旦保持するバッファ Uint8だとオーバーフローする
    let outputData = new Uint8Array(width * height);
    for (let i = 0; i < width * height; ++i) errorDiffusionBuffer[i] = u8array[i];

    for (let i = 0; i < height; i += 1) {
      for (let j = 0; j < width; j += 1) {
        let outputValue;
        let errorValue;
        const currentPositionValue = errorDiffusionBuffer[i * width + j];
        if (currentPositionValue >= 128) {
          outputValue = 255;
          errorValue = currentPositionValue - 255;
        } else {
          outputValue = 0;
          errorValue = currentPositionValue;
        }


        if (j < width - 1) {
          errorDiffusionBuffer[i * width + j + 1] += 5 * errorValue / 16 | 0;
        }
        if (0 < j && i < height - 1) {
          errorDiffusionBuffer[(i + 1) * width + j - 1] += 3 * errorValue / 16 | 0;
        }
        if (i < height - 1) {
          errorDiffusionBuffer[(i + 1) * width + j] += 5 * errorValue / 16 | 0;
        }
        if (j < width - 1 && i < height - 1) {
          errorDiffusionBuffer[(i + 1) * width + j + 1] += 3 * errorValue / 16 | 0;
        }
        outputData[i * width + j] = outputValue;
      }
    }
    return outputData;
  }
```


こちらはディザ法よりは自然な印象。

誤差拡散法(グレースケール)
![error_diffusion_gray.png](https://qiita-image-store.s3.amazonaws.com/0/28988/14f7c3a9-def4-1025-7d27-4375995fabe2.png)
誤差拡散法(カラー)
![error_diffusion_color.png](https://qiita-image-store.s3.amazonaws.com/0/28988/54ed6b80-def5-a79e-d975-77d5ebdc0340.png)


## その他

今回のコードでは、他にも以下を実装している。

* グレースケール処理、
* ガウシアンフィルタ
* ラプラシアンフィルタ
* モザイク処理

他の多くの人もやっていて、解説してもそれほど有用にならないと思うので解説はしない。

![gaussian.png](https://qiita-image-store.s3.amazonaws.com/0/28988/ff87a7f3-c98f-6d66-ab73-7ad40d80a25b.png)
![grayscale.png](https://qiita-image-store.s3.amazonaws.com/0/28988/c20dd74c-42b9-b5a7-0104-8af16fce022e.png)
![laplacian.png](https://qiita-image-store.s3.amazonaws.com/0/28988/757daa21-237e-2e81-753c-90cbafa19598.png)


# 全体のソースコード

色々機能を付けた結果、記事に記載するには長くなってしまった(画像処理部分は500行近い)
もし次の記事を書いた時に500行超えるようなら、githubへのリンクで載せる形式にしたい。

<details><summary>HTML部分</summary><div>

```html:index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>画像処理するやつ</title>
</head>
<body>
<div>
    <input type="button" value="load" id="load_button">
    <select id="image-size">
        <option>400 x auto</option>
        <option>25%</option>
        <option>50%</option>
        <option>100%</option>
    </select>
    <input type="file" id="select-file">
    <div id="file_detail"></div>
    <select id="process">
        <option>None</option>
        <option>Grayscale</option>
        <option>Dither</option>
        <option>DitherColor</option>
        <option>ErrorDiffusion</option>
        <option>ErrorDiffusionColor</option>
        <option>Laplacian</option>
        <option>Gaussian</option>
        <option>Mosaic</option>
    </select>
    <input type="button" value="Apply" id="apply_button">
    <input type="button" value="Save" id="save_button">
</div>
<canvas></canvas><br>
<script src="image.js"></script>
</body>
</html>
```

</div></details>


<details><summary>JavaScript部分</summary><div>

```javascript:image.js
"use strict";
let gImage = null;


function imload() {
  const files = document.getElementById("select-file").files;
  if (1 > files.length) return;
  const isImage =
    files[0].type === 'image/jpeg' ||
    files[0].type === 'image/bmp' ||
    files[0].type === 'image/png' ||
    files[0].type === 'image/gif';

  if (!isImage) return;
  const reader = new FileReader;
  reader.addEventListener('load', function (evt) {
    const _src = evt.target.result;
    const cvs = document.querySelector('canvas');
    const ctx = cvs.getContext('2d');
    ctx.clearRect(0, 0, cvs.width, cvs.height);
    

    const image_400px_auto = () => {
      cvs.width = 400;
      cvs.height = 400 * gImage.height / gImage.width;
    }
    const image_scale = (x) => {
      cvs.width = gImage.width / x;
      cvs.height = gImage.height / x;
    }

    gImage = new Image();
    gImage.src = _src;
    gImage.addEventListener('load', function () {
      switch (document.getElementById("image-size").selectedIndex) {
        case 0:
          image_400px_auto();
          break;

        case 1:
          image_scale(4);
          break;

        case 2:
          image_scale(2);
          break;

        case 3:
          image_scale(1);
          break;
      }
      ctx.drawImage(gImage, 0, 0, cvs.width, cvs.height);
    }, false);
  }, false);
  reader.readAsDataURL(files[0]);
}
document.getElementById("load_button").addEventListener('mousedown', imload, false);


function imgSave() {
  const cvs = document.querySelector('canvas');
  // const url = canvas.toDataURL("image/jpeg");
  const dataURL = cvs.toDataURL("image/png");
  let a = document.createElement("a");
  a.download = "image.png";
  a.href = dataURL;
  document.body.appendChild(a);
  a.click();
}
document.getElementById("save_button").addEventListener('mousedown', imgSave, false);


document.getElementById("select-file").addEventListener('change', (evt) => {
  const files = evt.target.files;
  document.getElementById("file_detail").innerHTML = "'" + files[0].name + "' " + files[0].type + " " + files[0].size + "bytes";
}, false);


document.getElementById("apply_button").addEventListener('mousedown', function () {
  const laplacian = [
    2, 0, 2,
    0, -8, 0,
    2, 0, 2
  ];
  const gaussian = [
    1, 2, 1,
    2, 4, 2,
    1, 2, 1
  ];


  const toGrayscale = function (array, width, height) {
    let outputArray = new Uint8Array(width * height);
    for (let i = 0; i < height; i += 4) {
      for (let j = 0; j < width; j += 4) {
        for (let dy = 0; dy < 4; ++dy) {
          for (let dx = 0; dx < 4; ++dx) {
            const r = array[((i + dy) * width + (j + dx)) * 4 + 0];
            const g = array[((i + dy) * width + (j + dx)) * 4 + 1];
            const b = array[((i + dy) * width + (j + dx)) * 4 + 2];
            const gray = (r + g + b) / 3 | 0;
            outputArray[(i + dy) * width + (j + dx)] = gray;
          }
        }
      }
    }
    return outputArray;
  }


  const errorDiffusion1CH = function (u8array, width, height) {
    let errorDiffusionBuffer = new Int16Array(width * height); // 誤差拡散法で元画像+処理誤差を一旦保持するバッファ Uint8だとオーバーフローする
    let outputData = new Uint8Array(width * height);
    for (let i = 0; i < width * height; ++i) errorDiffusionBuffer[i] = u8array[i];

    for (let i = 0; i < height; i += 1) {
      for (let j = 0; j < width; j += 1) {
        let outputValue;
        let errorValue;
        const currentPositionValue = errorDiffusionBuffer[i * width + j];
        if (currentPositionValue >= 128) {
          outputValue = 255;
          errorValue = currentPositionValue - 255;
        } else {
          outputValue = 0;
          errorValue = currentPositionValue;
        }


        if (j < width - 1) {
          errorDiffusionBuffer[i * width + j + 1] += 5 * errorValue / 16 | 0;
        }
        if (0 < j && i < height - 1) {
          errorDiffusionBuffer[(i + 1) * width + j - 1] += 3 * errorValue / 16 | 0;
        }
        if (i < height - 1) {
          errorDiffusionBuffer[(i + 1) * width + j] += 5 * errorValue / 16 | 0;
        }
        if (j < width - 1 && i < height - 1) {
          errorDiffusionBuffer[(i + 1) * width + j + 1] += 3 * errorValue / 16 | 0;
        }
        outputData[i * width + j] = outputValue;
      }
    }
    return outputData;
  }


  const dither1CH = function (u8array, width, height) {
    const bayer = [
      0, 8, 2, 10,
      12, 4, 14, 6,
      3, 11, 1, 9,
      15, 7, 13, 5
    ];
    const bayer2 = new Uint8Array(bayer.map(x => x * 16 + 8));
    let outputData = new Uint8Array(width * height);
    for (let i = 0; i < height; i += 4) {
      for (let j = 0; j < width; j += 4) {
        for (let dy = 0; dy < 4; ++dy) {
          for (let dx = 0; dx < 4; ++dx) {
            const value = u8array[(i + dy) * width + (j + dx)];

            if (value >= bayer2[dy * 4 + dx]) {
              outputData[(i + dy) * width + (j + dx)] = 0xff;
            } else {
              outputData[(i + dy) * width + (j + dx)] = 0x00;
            }
          }
        }
      }
    }
    return outputData;
  }


  const processRGBChannel = function (u8arrayRGBA, width, height, func) {
    let rArray = new Uint8Array(width * height);
    let gArray = new Uint8Array(width * height);
    let bArray = new Uint8Array(width * height);

    for (let i = 0; i < height; i += 1) {
      for (let j = 0; j < width; j += 1) {
        rArray[i * width + j] = u8arrayRGBA[(i * width + j) * 4 + 0];
        gArray[i * width + j] = u8arrayRGBA[(i * width + j) * 4 + 1];
        bArray[i * width + j] = u8arrayRGBA[(i * width + j) * 4 + 2];
      }
    }
    const outputR = func(rArray, width, height);
    const outputG = func(gArray, width, height);
    const outputB = func(bArray, width, height);

    return {
      r: outputR,
      g: outputG,
      b: outputB
    }
  }


  const drawPlainImage = function () {
    const cvs = document.querySelector('canvas');
    const ctx = cvs.getContext('2d');
    ctx.clearRect(0, 0, cvs.width, cvs.height)
    ctx.drawImage(gImage, 0, 0, cvs.width, cvs.height);
  }


  const mosaic = function (w, h) {
    const cvs = document.querySelector('canvas');
    const ctx = cvs.getContext('2d');
    const inputData = ctx.getImageData(0, 0, cvs.width, cvs.height).data;

    const output = ctx.createImageData(cvs.width, cvs.height);
    let outputData = output.data;

    for (let i = 0; i < cvs.height; i += h) {
      for (let j = 0; j < cvs.width; j += w) {
        let sumr = 0;
        let sumg = 0;
        let sumb = 0;
        let cnt = 0;
        for (let dy = 0; dy < h; ++dy) {
          for (let dx = 0; dx < w; ++dx) {
            ++cnt;
            sumr += inputData[((i + dy) * cvs.width + (j + dx)) * 4 + 0];
            sumg += inputData[((i + dy) * cvs.width + (j + dx)) * 4 + 1];
            sumb += inputData[((i + dy) * cvs.width + (j + dx)) * 4 + 2];
          }
        }
        const averageR = sumr / cnt | 0;
        const averageG = sumg / cnt | 0;
        const averageB = sumb / cnt | 0;
        for (let dy = 0; dy < h; ++dy) {
          for (let dx = 0; dx < w; ++dx) {
            outputData[((i + dy) * cvs.width + j + dx) * 4 + 0] = averageR;
            outputData[((i + dy) * cvs.width + j + dx) * 4 + 1] = averageG;
            outputData[((i + dy) * cvs.width + j + dx) * 4 + 2] = averageB;
            outputData[((i + dy) * cvs.width + j + dx) * 4 + 3] = 0xff;
          }
        }
      }
    }
    ctx.putImageData(output, 0, 0);
  }


  const applyMatrix = function (arr, mode, k) {
    if (!k) k = 1;
    const cvs = document.querySelector('canvas');
    const ctx = cvs.getContext('2d');
    const inputData = ctx.getImageData(0, 0, cvs.width, cvs.height).data;

    const output = ctx.createImageData(cvs.width, cvs.height);
    let outputData = output.data;

    for (let i = 1; i < cvs.height - 1; ++i) {
      for (let j = 1; j < cvs.width - 1; ++j) {
        let valr = 0;
        let valg = 0;
        let valb = 0;
        for (let dy = -1; dy <= 1; ++dy) {
          for (let dx = -1; dx <= 1; ++dx) {
            const value = arr[(dy + 1) * 3 + dx + 1]
            valr += inputData[((i + dy) * cvs.width + (j + dx)) * 4 + 0] * value;
            valg += inputData[((i + dy) * cvs.width + (j + dx)) * 4 + 1] * value;
            valb += inputData[((i + dy) * cvs.width + (j + dx)) * 4 + 2] * value;
          }
        }
        // かさ上げで見ているが、絶対値で見たいときは書き換える
        valr = valr / k | 0;
        valg = valg / k | 0;
        valb = valb / k | 0;
        if( "bias" === mode ){
          valr = 128 + valr;
          valg = 128 + valg;
          valb = 128 + valb;
        }
        else if( "abs" === mode ){
          valr = (valr > 0) ? valr : -valr;
          valg = (valg > 0) ? valg : -valg;
          valb = (valb > 0) ? valb : -valb;
        }

        

        outputData[(i * cvs.width + j) * 4 + 0] = valr;
        outputData[(i * cvs.width + j) * 4 + 1] = valg;
        outputData[(i * cvs.width + j) * 4 + 2] = valb;
        outputData[(i * cvs.width + j) * 4 + 3] = 0xff;
      }
    }
    ctx.putImageData(output, 0, 0);
  }


  const processRGBChannelAndOutput = function (func) {
    const cvs = document.querySelector('canvas');
    const ctx = cvs.getContext('2d');
    const inputData = ctx.getImageData(0, 0, cvs.width, cvs.height).data;

    const output = ctx.createImageData(cvs.width, cvs.height);
    let outputData = output.data;

    const outputProcessed = processRGBChannel(inputData, cvs.width, cvs.height, func);

    for (let i = 0; i < cvs.height; i += 1) {
      for (let j = 0; j < cvs.width; j += 1) {
        outputData[(i * cvs.width + j) * 4 + 0] = outputProcessed.r[i * cvs.width + j];
        outputData[(i * cvs.width + j) * 4 + 1] = outputProcessed.g[i * cvs.width + j];
        outputData[(i * cvs.width + j) * 4 + 2] = outputProcessed.b[i * cvs.width + j];
        outputData[(i * cvs.width + j) * 4 + 3] = 0xff;
      }
    }
    ctx.putImageData(output, 0, 0);
  }


  const processGrayAndOutput = function (func) {
    const cvs = document.querySelector('canvas');
    const ctx = cvs.getContext('2d');
    const inputData = ctx.getImageData(0, 0, cvs.width, cvs.height).data;

    const output = ctx.createImageData(cvs.width, cvs.height);
    let outputData = output.data;

    const grayArray = toGrayscale(inputData, cvs.width, cvs.height);
    const funcOutput = func(grayArray, cvs.width, cvs.height)
    for (let i = 0; i < cvs.height; i += 1) {
      for (let j = 0; j < cvs.width; j += 1) {
        const value = funcOutput[i * cvs.width + j];

        outputData[(i * cvs.width + j) * 4 + 0] = value;
        outputData[(i * cvs.width + j) * 4 + 1] = value;
        outputData[(i * cvs.width + j) * 4 + 2] = value;
        outputData[(i * cvs.width + j) * 4 + 3] = 0xff;
      }
    }
    ctx.putImageData(output, 0, 0);
  }


  const dither = function () {
    processGrayAndOutput(dither1CH);
  }


  const ditherColor = function () {
    processRGBChannelAndOutput(dither1CH);
  }


  const errorDiffusionMethod = () => {
    processGrayAndOutput(errorDiffusion1CH);
  }


  const errorDiffusionColor = () => {
    processRGBChannelAndOutput(errorDiffusion1CH);
  }

  
  const grayscale = function () {
    const cvs = document.querySelector('canvas');
    const ctx = cvs.getContext('2d');
    const inputData = ctx.getImageData(0, 0, cvs.width, cvs.height).data;

    const output = ctx.createImageData(cvs.width, cvs.height);
    let outputData = output.data;
    const grayArray = toGrayscale(inputData, cvs.width, cvs.height);

    for (let i = 0; i < cvs.height; i += 1) {
      for (let j = 0; j < cvs.width; j += 1) {
        outputData[(i * cvs.width + j) * 4 + 0] = grayArray[i * cvs.width + j];
        outputData[(i * cvs.width + j) * 4 + 1] = grayArray[i * cvs.width + j];
        outputData[(i * cvs.width + j) * 4 + 2] = grayArray[i * cvs.width + j];
        outputData[(i * cvs.width + j) * 4 + 3] = 0xff;
      }
    }

    ctx.putImageData(output, 0, 0);
  }

  const start = new Date();
  switch (document.getElementById("process").selectedIndex) {
    case 0:
      drawPlainImage();
      break;

    case 1:
      drawPlainImage();
      grayscale();
      break;

    case 2:
      drawPlainImage();
      dither();
      break;

    case 3:
      drawPlainImage();
      ditherColor();
      break;

    case 4:
      drawPlainImage();
      errorDiffusionMethod();
      break;

    case 5:
      drawPlainImage();
      errorDiffusionColor();
      break;
      
    case 6:
      drawPlainImage();
      applyMatrix(laplacian, "bias");
      break;

    case 7:
      drawPlainImage();
      applyMatrix(gaussian, "abs", 16);
      break;

    case 8:
      drawPlainImage();
      mosaic(10, 10);
      break;
      
  }
  const end = new Date();
  console.log(end.getTime() - start.getTime());
}, false);
```

</div></details>

# 参考ページ

[^1]: [ハーフトーン処理(誤差拡散法) - [物理のかぎしっぽ]](http://hooktail.org/computer/index.php?%A5%CF%A1%BC%A5%D5%A5%C8%A1%BC%A5%F3%BD%E8%CD%FD%28%B8%ED%BA%B9%B3%C8%BB%B6%CB%A1%29)

* [ハーフトーン処理(ディザ法) - [物理のかぎしっぽ]](http://hooktail.org/computer/index.php?%A5%CF%A1%BC%A5%D5%A5%C8%A1%BC%A5%F3%BD%E8%CD%FD%28%A5%C7%A5%A3%A5%B6%CB%A1%29)