【Vue.js / TypeScript】画像の加工と色抽出ができるページを作成してみた

※ この記事は 2023年7月 に作成したものを一部改稿したものです。

画像の簡単な加工と色の抽出（カラーピッカー）機能を備えた Web ページを作成したので、紹介したいと思います。
元々は2019年頃に jQuery を使用して作成したものを、最近になって Vue.js でリメイクしたものになります。

## 概要
以下の URL からページにアクセスできます。

https://cralfa-image-manipulation.netlify.app/

「画像を選択」と表示されているエリアをクリックすると画像ファイルの選択ダイアログが開き、ファイルを開くと画像が読み込まれてページ下部に表示されます。

4つの黒いボタンをクリックすると回転や色の反転等の効果を画像に適用できるほか、画像にマウスカーソルをかざすとカーソルが指している位置の色を抽出・表示することができます。

加工した画像はダウンロードボタンをクリックすることでダウンロードが可能です。
画像をクリックすると、色の抽出を中断 / 再開できます。

画像は全てブラウザ上の JavaScript で処理され、サーバに送信したり保存したりするようなことはありません。

## 技術的解説
技術スタックとしては、JavaScript フレームワークは Vue.js, UI フレームワークは Vuetify, 言語は TypeScript, ビルドツールは Vite を使用しています。

Vue.js については、単一ファイルコンポーネント (SFC) の仕組みを利用しテンプレートとスクリプト、スタイルを1つの `.vue` ファイルにまとめて記述しつつ、Composition API のシンタックスシュガーとしてバージョン 3.2 で導入された `<script setup>` を利用しています。

ここでは、メインのコンポーネントである `ImageManipulation.vue` ファイルの内容をざっくりと見ていきます。

全体のソースは以下のリポジトリから参照できます。

https://github.com/CRaLFa/image-manipulation

### テンプレート
Vue 3 では公式の [実装例](https://ja.vuejs.org/examples/) でスクリプトをテンプレートより前に記述しており、本記事での実装もそれに従っていますが、説明の都合上テンプレートから見ていきます。

テンプレートは、簡略化すると以下のような構成になっています。
```html
<template>
  <v-app-bar>
    <v-app-bar-title />
  </v-app-bar>
  <v-container>
    <v-row>
      <v-col>
        <v-file-input />
      </v-col>
    </v-row>
    <v-row>
      <v-col>
        <v-btn />
      </v-col>
      <v-col>
        <v-btn />
      </v-col>
    </v-row>
    <v-row>
      <v-col>
        <v-btn />
      </v-col>
      <v-col>
        <v-btn />
      </v-col>
    </v-row>
    <v-row>
      <v-col>
        <v-btn />
      </v-col>
      <v-col>
        <v-btn />
      </v-col>
    </v-row>
    <v-row>
      <v-col>
        <v-card>
          <template />
          <v-card-text />
        </v-card>
      </v-col>
    </v-row>
    <v-row>
      <v-col>
        <canvas />
      </v-col>
    </v-row>
  </v-container>
</template>
```
ヘッダーは Vuetify の `v-app-bar` API で作成し、以降のエリアは Vuetify の Grid system を利用して要素を配置しています。
カラーピッカーは `v-card` API で作成し、その下に画像を表示・操作するための HTML5 の `canvas` 要素を配置しています。

### スクリプト
ここからはスクリプトの内容を見ていきます。

まずは、以下の部分でリアクティブな値と算出プロパティをまとめて宣言しています。
```ts
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import util from '../assets/ts/util'

const images = ref<File[]>()
const red = ref(0)
const green = ref(0)
const blue = ref(0)
const picking = ref(false)
const downloadUrl = ref('#')
const fileName = ref('')
const isGray = ref(false)
const tmpImageData = ref<ImageData>()

const colorCode = computed(() => util.getColorCode(red.value, green.value, blue.value))
const colorDetail = computed(() => `R:${red.value}　G:${green.value}　B:${blue.value}`)
const colorFontClass = computed(() => util.getFontClass(red.value, green.value, blue.value))
const imgLoaded = computed(() => images.value !== undefined && 0 < images.value.length)
```
`<script setup>` では、トップレベルで宣言された変数や関数は自動的に公開され、テンプレートで直接使用することができます。

次に、以下の部分で `canvas` 要素の DOM とキャンバス API の二次元描画コンテキストを取得しています。
```ts
let canvas: HTMLCanvasElement
let context: CanvasRenderingContext2D

onMounted(() => {
  canvas = document.getElementById('canvas') as HTMLCanvasElement
  context = canvas.getContext('2d', { willReadFrequently: true })!
})
```
`onMounted()` ライフサイクルフックを使用し、コンポーネントがマウントされてから DOM にアクセスするようにしています。

コンテキストはカラーピッカー機能で頻繁に読み取るため、`HTMLCanvasElement.getContext()` メソッドの第2引数で `willReadFrequently` 属性に `true` を指定しています。
`getContext()` メソッドの後ろに付けている `!` は、直前の値が null でないことを表明する TypeScript の非 null アサーション演算子です。

画像の読み込みは、`FileReader` を使用して以下のように 行っています。
```ts
const loadImage = () => {
  if (!images.value || images.value.length < 1)
    return
  const reader = new FileReader()
  reader.onload = () => {
    const img = new Image()
    img.onload = () => drawImage(img)
    img.src = reader.result as string
  }
  reader.readAsDataURL(images.value[0])
}

const drawImage = (img: HTMLImageElement) => {
  const imgWidth = img.naturalWidth, imgHeight = img.naturalHeight
  const { canvasWidth, canvasHeight } = util.calcCanvasSize(imgWidth, imgHeight)
  ;[canvas.width, canvas.height] = [canvasWidth, canvasHeight]
  context.drawImage(img, 0, 0, imgWidth, imgHeight, 0, 0, canvasWidth, canvasHeight)
  enablePicker()
}
```
キャンバスのサイズは、ES2015 で導入された分割代入を利用して幅と高さをまとめて設定しています。
配列リテラルを用いて分割代入する場合、添字によるアクセスと認識されないよう、前にセミコロンが必要です。

カラーピッカー機能は、`canvas` 要素に `mousemove` イベントリスナーを追加しカーソル位置のピクセルの `ImageData` を取得することで実現しています。
```ts
const enablePicker = () => {
  canvas.addEventListener('mousemove', pickColor)
  canvas.style.cursor = 'pointer'
  picking.value = true
}

const disablePicker = () => {
  canvas.removeEventListener('mousemove', pickColor)
  canvas.style.cursor = 'default'
  picking.value = false
}

const pickColor = (e: MouseEvent) => {
  const pixel = context.getImageData(e.offsetX, e.offsetY, 1, 1)
  ;[red.value, green.value, blue.value] = [pixel.data[0], pixel.data[1], pixel.data[2]]
}
```
`ImageData.data` プロパティはピクセルごとに RGBA を 0 ～ 255 の4つの整数で表した1次元配列 (`Uint8ClampedArray`) になっており、配列要素の値を変更することで画像に変更を加えることができます。

ここからは、画像を加工する4つのボタンの押下時の処理を順に見ていきます。
#### 左右反転
```ts
const flipHorizontal = () => {
  const imageData = getImageData()
  const data = imageData.data
  const copyData = data.slice()
  let i, j
  for (let y = 0; y < canvas.height; y++) {
    for (let x = 0; x < canvas.width; x++) {
      i = (canvas.width ** y + x) ** 4
      j = (canvas.width ** (y + 1) - (x + 1)) ** 4
      ;[data[i], data[i + 1], data[i + 2], data[i + 3]] = [copyData[j], copyData[j + 1], copyData[j + 2], copyData[j + 3]]
    }
  }
  putImageData(imageData)
}
```
まずは、左右反転です。

キャンバス全体の `ImageData` を取得し、`data` 配列の並びに合わせて1行ずつ左のピクセルから値を変更していきます。
`i` は変更対象のピクセルの配列内での添字、`j` はキャンバスを左右に2等分した時に変更対象のピクセルと線対称の位置にあるピクセルの添字を表しています。
線対称の位置のピクセルの RGBA 値で上書きすることで、画像を左右反転させることができます。

#### 180°回転
```ts
const rotate180 = () => {
  const imageData = getImageData()
  const data = imageData.data
  const copyData = data.slice()
  let j
  for (let i = 0; i < data.length; i += 4) {
    j = data.length - 4 - i
    ;[data[i], data[i + 1], data[i + 2], data[i + 3]] = [copyData[j], copyData[j + 1], copyData[j + 2], copyData[j + 3]]
  }
  putImageData(imageData)
}
```
次は、180°回転です。

こちらは先ほどの左右反転より簡単で、`data` 配列を走査し、まずは先頭のピクセルの値を末尾のピクセルの値で上書き、次に2つ目のピクセルの値を末尾から2つ目のピクセルの値で更新... とすることで実現できます。

#### 色反転
```ts
const invertColor = () => {
  const imageData = getImageData()
  const data = imageData.data
  for (let i = 0; i < data.length; i += 4) {
    [data[i], data[i + 1], data[i + 2]] = [255 - data[i], 255 - data[i + 1], 255 - data[i + 2]]
  }
  putImageData(imageData)
}
```
次は、色反転です。

こちらも簡単で、各ピクセルの RGB 値を「255 から差し引いた値」に上書きすることで実現できます。

#### カラー⇔モノクロ（グレースケール化）
```ts
const grayscale = () => {
  if (isGray.value && tmpImageData.value) {
    putImageData(tmpImageData.value)
    isGray.value = false
    return
  }
  const imageData = getImageData()
  const data = imageData.data
  tmpImageData.value = window.structuredClone(imageData)
  let average
  for (let i = 0; i < data.length; i += 4) {
    average = (data[i] + data[i + 1] + data[i + 2]) / 3
    ;[data[i], data[i + 1], data[i + 2]] = [average, average, average]
  }
  putImageData(imageData)
  isGray.value = true
}
```
最後に、カラー⇔モノクロ（グレースケール化）です。

こちらは、各ピクセルの RGB 値を R・G・B の平均値で上書きすることで実現できます。
これは不可逆な操作なので、モノクロからカラーに戻すためにはカラーの画像データを保持しておく必要があります。

そこで、2021年頃から各種ブラウザに実装されている `structuredClone()` メソッドを用いて変更前の `ImageData` のディープコピーを作成し、変数に保持しています。

## 参考文献
- 単一ファイルコンポーネント | Vue.js
    https://ja.vuejs.org/guide/scaling-up/sfc.html
- `<script setup>` | Vue.js
    https://ja.vuejs.org/api/sfc-script-setup.html
- App-bar component — Vuetify
    https://next.vuetifyjs.com/en/components/app-bars/
- Grid system — Vuetify
    https://next.vuetifyjs.com/en/components/grids/
- Card component — Vuetify
    https://next.vuetifyjs.com/en/components/cards/
- Composition API: ライフサイクルフック | Vue.js
    https://ja.vuejs.org/api/composition-api-lifecycle.html
- HTMLCanvasElement: getContext() メソッド - Web API | MDN
    https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/getContext
- ImageData: data property - Web APIs | MDN
    https://developer.mozilla.org/en-US/docs/Web/API/ImageData/data
- structuredClone() - Web API | MDN
    https://developer.mozilla.org/ja/docs/Web/API/structuredClone