LoginSignup
11
5

More than 3 years have passed since last update.

Vue.jsを勉強したのでドット絵作成アプリを作ってみる

Last updated at Posted at 2020-05-10

はじめに

フロントエンド何もわからないマンであるところの筆者が、Vue.jsに入門がてらの練習としてちょっとした画像変換アプリを作ってみました。
この記事では、「Vue.jsの基本文法を一通り勉強してみたけどVue CLIの使い方はよくわからない、そもそもJavaScriptもそんなにわかってない」くらいのレベルから実際にVue CLIを触ってアプリを作る過程でよくわからなかったポイントや注意点をつらつらと記録していきます。

アプリの完成品

作ったアプリはこちらです。
https://tkmz-n.github.io/pixelize_app/

「クロスステッチの図案を作成したい」との需要があったので、好きな画像をアップロードしてドット絵に変換し、図案として利用できるようなものを作りました。
ググってみたところ似たようなアプリはいくつか見つかるんですが、サイズ指定や色数の指定に制限があったりしてもどかしいので、そのあたりを柔軟に設定できるようにします。
画像をアップロードして、変換後の画像サイズや色数、ドットをわかりやすくするグリッド線の有無などを設定し、Pixelize!!!ボタンを押下して変換を実行します。

画面

環境準備

  • Mac OS X Mojave 10.14.6
  • Node.js 14.0.0
  • Vue-CLI 4.3.1

以下を参考にNode.jsをインストールしました。
MacにNode.jsをインストール

次に以下のようにVue CLIをインストールします。2020年4月時点では4系がリリースされているのでそれを使いますが、適当にググると2系の古い情報が出てくるので注意。

npm install -g @vue/cli

間違えて古いバージョンを入れちゃったら、アンインストールしてから新しい方をインストールし直します。

# 2系をアンインストール
npm uninstall -g vue-cli

アプリ作成

プロジェクトの初期化

こちらも適当にググると、vue initを使ったりvue createを使ったりする方法が入り混じっていて混乱しますが、vue initは2系までの古いやり方な気がします。vue createしましょう。
以下のコマンドで、プロジェクトを初期化して必要なファイルをいろいろ作ってくれます。ここでは、プロジェクト名を「pixelize-app」としています。

vue create pixelize-app

実行すると、デフォルト設定で作成するかマニュアルでいろいろ選ぶか聞かれます。今回はとりあえずデフォルト設定にします。
デフォルトではVue Routerも入らないので、必要ならマニュアルにしましょう。

初期化されたプロジェクトを見てみる

pixelize-appフォルダが作成されました。中身を眺めつつ、多分こうなってるんだろうというメモ。

index.html

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

"app"というidが定義されている<div>タグの中身に、vueの要素が入るはず。このhtml自体は特にいじらない。

src/main.js

src/main.js
import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

App.vueをimportして、さっき見たindex.htmlの"app"にマウントしている。これも特にいじらなくてよさそう。

src/App.vue

src/App.vue

<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'

export default {
  name: 'App',
  components: {
    HelloWorld
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

ここが画面を作るメイン。
<template>にhtmlのテンプレート、
<script>にVueのスクリプト、
<style>にCSS
をまとめて1ファイルにしている。
VueコンポーネントとしてHelloWorldを読み込んでいる

components/HelloWorld.vue

App.vueから読み込まれるコンポーネントのサンプル。

とりあえず書いてみる

基本的にはApp.vueを中心としつつ、必要に応じてcomponents内にコンポーネントを作成していくんだなというのはわかりました。
今回作るのは機能が1つだけのシンプルなアプリでいいのでApp.vueにいろいろ書いていってもいいんですが、拡張性とかも考えたら別途コンポーネントとして作っていくのがお行儀がいいんだろうなという気がします。
components/Pixelize.vueファイルを作成し、その中にいろいろ書いていくことにします。
components/HelloWorld.vueは削除し、App.vueは以下のように書き換えます(HelloWorld → Pixelize)。

src/App.vue
<template>
  <div id="app">
    <Pixelize/>
  </div>
</template>

<script>
import Pixelize from './components/Pixelize.vue'

export default {
  name: 'App',
  components: {
    Pixelize
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

実装上の注意

.jsファイルにVueインスタンスを定義する場合とは違い、.vueファイルに定義する場合は書き方がいろいろと異なるようです(というよりも、コンポーネントとして書く場合?)。

  • export default { ... }という形式で書く
  • dataは関数としてdata(){}という形式で書く(Vueコンポーネントの元になっているVueインスタンスにあるdataと干渉するのを避けるため)

画像をアップロードして画面に表示させる

そもそもJavaScriptでファイルや画像をどう扱うのかもよくわかっていないので、調べながらやっていきます。
参考:

まず、Pixelizeコンポーネントのテンプレート部分を以下のようにしました。

src/components/Pixelize.vue
<template>
  <div class="pixelize">
    <h1>Pixelize!!!</h1>
    <div>
      <label>
        <input type="file" v-on:change="upload" accept="image/*" />
      </label>
    </div>
    <p>オリジナル画像の幅:{{ widthBefore }}</p>
    <p>オリジナル画像の高さ:{{ heightBefore }}</p>
    <div>
      <h2>変換前プレビュー</h2>
      <canvas id="preview-before"></canvas>
    </div>
  </div>
</template>

ファイルアップロード用のinputタグを用意し、v-onディレクティブを定義します。これにより、フォームの変化(changeイベント)に反応してuploadメソッドが呼ばれるようになります。accept="image/*"を指定することで、受け付けるファイルを画像のみに制限しています。
変換前画像の画像サイズ情報として、widthBeforeheightBeforeを表示してみます。
HTML5ではcanvasタグを使って簡単に画像を表示できるらしい。

Vueコンポーネントは次のようにします。

src/components/Pixelize.vue
<script>
export default {
  name: "Pixelize",
  data() {
    return {
      widthBefore: 0,
      heightBefore: 0,
    };
  },
  methods: {
    upload: function(event) {
      let img = null;
      let file = event.target.files;
      if (file.length == 0) {
        return;
      }
      let reader = new FileReader();
      reader.readAsDataURL(file[0]);

      // ファイルを読み込んだときの処理
      reader.onload = function() {
        img = new Image();
        // 画像が読み込まれたときの処理(canvasに描画)
        img.onload = function() {
          let canvas = document.getElementById("preview-before");
          if (canvas.getContext) {
            let context = canvas.getContext("2d");
            this.widthBefore = img.width;
            this.heightBefore = img.height;
            canvas.width = this.widthBefore;
            canvas.height = this.heightBefore;
            context.drawImage(img, 0, 0);
          }
        }.bind(this);
        img.src = reader.result;
      }.bind(this);
    }
  }
};
</script>

dataにwidthBeforeheightBeforeを定義し、さらに画像アップロード時に呼び出すuploadメソッドを持たせています。
細かいところは写経ですが、要はimg.onloadに定義した処理が、画像が読み込まれた際に実行されます。ここではcanvasに描画を行い、同時に自身のdataに画像のサイズ情報を渡しています。
thisがちゃんとVueインスタンス自身を指して正しくdataに情報が入るように、メソッド定義に.bind(this)を付ける必要がある……という理解でいます。

画像を変換(ピクセル化)

メインの処理である画像変換スクリプトを作成します。
OpenCV.jsを使ったりすると楽かもしれませんが、今回はJavaScriptの練習がてらスクラッチで書いていきます。
今回実現したいのは、元画像をいい感じのドット絵に変換することです。これは、

  • 解像度を落とす
  • 色の種類を減らす(減色)

という処理によって実現できます。
解像度を落とすところは画像の縮小でなんとかするとして、ここでは減色する処理を実装します。具体的には、各ピクセルをRGBの三次元ベクトルと見なしてk-meansによってクラスタリングし、近い色同士をまとめます。各ピクセルの値をクラスタの代表値(平均)に置き換えます。
以下のようなメソッドを実装しました。

src/components/Pixelize.vue
const kMeansFilter = (src, dst, width, height, colors) => {
  const vmax = 255; // 配列要素の最大値
  const loopMax = 100; // ループ処理の最大回数

  // 初期化
  colors = parseInt(colors);
  let centroids = Array(colors); // 各クラスタ中心を保持
  for (var c = 0; c < colors; c++) {
    var rand_i = Math.floor(Math.random() * height);
    var rand_j = Math.floor(Math.random() * width);
    centroids[c] = src.slice(
      (rand_j + rand_i * width) * 4,
      (rand_j + rand_i * width) * 4 + 3
    );
  }

  let clsts = Array(width * height); // 各画素の所属クラスタラベル(0~colors-1)を保持
  let clstsSum = Array(colors); // 各クラスタの重心計算用
  for (var c = 0; c < colors; c++) {
    clstsSum[c] = Array(3);
  }
  let clstsSize = Array(colors); // 各クラスタの重心計算用
  let count = 0;

  // メイン処理
  let clstsPrev = JSON.parse(JSON.stringify(clsts));
  let exitFlg = false;
  while (true) {
    for (var i = 0; i < height; i++) {
      for (var j = 0; j < width; j++) {
        var vec = src.slice((j + i * width) * 4, (j + i * width) * 4 + 3);
        var minDist = calcDistance(vec, centroids[0]);
        var minClst = 0;
        for (var c = 1; c < colors; c++) {
          var nextDist = calcDistance(vec, centroids[c]);
          if (nextDist < minDist) {
            minDist = nextDist;
            minClst = c;
          }
        }
        clsts[j + i * width] = minClst;
      }
    }
    // update centroids
    clstsSize.fill(0);
    for (var c = 0; c < colors; c++) {
      clstsSum[c].fill(0);
    }
    for (var i = 0; i < height; i++) {
      for (var j = 0; j < width; j++) {
        var clst = clsts[j + i * width];
        for (var k = 0; k < 3; k++) {
          clstsSum[clst][k] += src[(j + i * width) * 4 + k];
        }
        clstsSize[clst] = clstsSize[clst] + 1;
      }
    }
    for (var c = 0; c < colors; c++) {
      for (var k = 0; k < 3; k++) {
        centroids[c][k] =
          clstsSize[c] > 0 ? Math.floor(clstsSum[c][k] / clstsSize[c]) : 0;
      }
    }

    exitFlg =
      JSON.stringify(clsts) === JSON.stringify(clstsPrev) || count > loopMax;
    if (exitFlg) {
      break;
    }
    clstsPrev = JSON.parse(JSON.stringify(clsts));
    count++;
  }

  // クラスタリング結果を反映
  for (var i = 0; i < height; i++) {
    for (var j = 0; j < width; j++) {
      var clst = clsts[j + i * width];
      for (var k = 0; k < 3; k++) {
        dst[(j + i * width) * 4 + k] = centroids[clst][k];
        // 透明度は維持
        dst[(j + i * width) * 4 + 3] = src[(j + i * width) * 4 + 3];
      }
    }
  }
};

// ベクトル間距離
const calcDistance = (vec1, vec2) => {
  let dist = 0;
  for (var i = 0; i < vec1.length; i++) {
    dist += Math.pow(Math.abs(vec2[i] - vec1[i]), 2);
  }
  dist = Math.sqrt(dist);
  return dist;
};

画像はimageData形式に変換すると、imageData.data配列の中身がRGBAの値を画素ごとに並べた1次元配列になるので、これを直接いじるメソッドを定義してやればいいです。
引数colorsに色数を指定し、それをクラスタ数としてクラスタリングします。

変換後画像の表示

あとは、変換後画像をいい感じに可視化します。
ピクセルのサイズを指定して拡大し、さらに図案として利用しやすいようにグリッド線をつけられるようにしてみました。
実装は、地道にimageData配列をいじっていくだけです。

src/components/Pixelize.vue
const visualizePixel = (inputImageData, pixelSize, grid) => {
  const vmax = 255; // 配列要素の最大値
  const gridStep = 10; // グリッド線をgridStepごとに太くする

  const newWidth = inputImageData.width * pixelSize;
  const newHeight = inputImageData.height * pixelSize;
  const outputImageData = new ImageData(
    inputImageData.width * pixelSize,
    inputImageData.height * pixelSize
  );
  // 拡大
  for (var i = 0; i < newHeight; i++) {
    for (var j = 0; j < newWidth; j++) {
      var iOld = Math.floor(i / pixelSize);
      var jOld = Math.floor(j / pixelSize);
      for (var k = 0; k < 4; k++) {
        outputImageData.data[(j + i * newWidth) * 4 + k] =
          inputImageData.data[(jOld + iOld * inputImageData.width) * 4 + k];
      }
    }
  }
  // グリッド線
  if (grid) {
    for (var i = 0; i < newHeight; i++) {
      for (var j = 0; j < newWidth; j++) {
        if (
          i % pixelSize == 0 ||
          j % pixelSize == 0 ||
          (i + 1) % (pixelSize * gridStep) == 0 ||
          (j + 1) % (pixelSize * gridStep) == 0
        ) {
          for (var k = 0; k < 3; k++) {
            outputImageData.data[(j + i * newWidth) * 4 + k] = vmax;
          }
          outputImageData.data[(j + i * newWidth) * 4 + k] = vmax;
        }
      }
    }
  }
  return outputImageData;
};

以上で、こんな感じにドット絵化できました。
(k-meansの性質上、初期値によってはあまりきれいにいかないことがあります)

  • オリジナル画像
    オリジナル画像

  • グリッド線なし
    グリッド線なし

  • グリッド線あり
    グリッド線あり

仕上げ

formタグで変換後の画像サイズや色数、ピクセルサイズ、グリッド線の有無を設定して変換実行する実装を加え、画面を仕上げます。
これらの設定値はVueインスタンスのdata属性に持たせるようにしています。
冗長になってしまうのでここにすべては貼りませんが、実装は以下にあります。

デプロイ

クライアントのみで動作するWebページはGitHub Pagesで公開するのが便利。
Vue CLIでビルドしたものをデプロイする手順もだいぶ簡単でした。
参考:Vue-Cli 3で開発したアプリケーションをGithub Pagesにデプロイする

補足

ESLintの設定

ESLintを入れていると、設定したルールに従ってJavaScriptが記述できているかをチェックしてくれます。ルールは個別に設定することもできますし、「recommended」と設定しておすすめの設定を利用することもできます。
で、Vue CLIでデフォルトのままプロジェクトを作成すると「recommended」状態になっているんですが、これが結構厳しいので部分的に無効にしたくなったりします(while(true){}を認めてくれない、とか……)。
諸々の設定が含まれているpackage.jsonのESLintに関する以下の部分を修正します。rulesの中に特定のルール名を記載し、offにしています。

package.json
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "babel-eslint"
    },
    "rules": {
      "no-redeclare": [
        "off"
      ],
      "no-constant-condition": [
        "off"
      ],
      "no-unused-vars": [
        "off"
      ]
    }
  },

デバッガの利用

以下を参考にしてデバッガの設定を行いました。
途中出てくるvue.config.jsについては、プロジェクト直下に新たにファイルを作成する必要があります。

まとめ

Vue CLIの基本的な機能を使ってドット絵作成アプリを作成しました。
今後気が向いたら、ローディング画面の追加等アップデートしていきたいと思います。

11
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
5