はじめに
フロントエンド何もわからないマンであるところの筆者が、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
<!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
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
<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)。
<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コンポーネントのテンプレート部分を以下のようにしました。
<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/*"
を指定することで、受け付けるファイルを画像のみに制限しています。
変換前画像の画像サイズ情報として、widthBefore
、heightBefore
を表示してみます。
HTML5ではcanvas
タグを使って簡単に画像を表示できるらしい。
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にwidthBefore
、heightBefore
を定義し、さらに画像アップロード時に呼び出すupload
メソッドを持たせています。
細かいところは写経ですが、要はimg.onload
に定義した処理が、画像が読み込まれた際に実行されます。ここではcanvas
に描画を行い、同時に自身のdataに画像のサイズ情報を渡しています。
this
がちゃんとVueインスタンス自身を指して正しくdataに情報が入るように、メソッド定義に.bind(this)
を付ける必要がある……という理解でいます。
画像を変換(ピクセル化)
メインの処理である画像変換スクリプトを作成します。
OpenCV.jsを使ったりすると楽かもしれませんが、今回はJavaScriptの練習がてらスクラッチで書いていきます。
今回実現したいのは、元画像をいい感じのドット絵に変換することです。これは、
- 解像度を落とす
- 色の種類を減らす(減色)
という処理によって実現できます。
解像度を落とすところは画像の縮小でなんとかするとして、ここでは減色する処理を実装します。具体的には、各ピクセルをRGBの三次元ベクトルと見なしてk-meansによってクラスタリングし、近い色同士をまとめます。各ピクセルの値をクラスタの代表値(平均)に置き換えます。
以下のようなメソッドを実装しました。
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配列をいじっていくだけです。
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
にしています。
"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の基本的な機能を使ってドット絵作成アプリを作成しました。
今後気が向いたら、ローディング画面の追加等アップデートしていきたいと思います。