無料公開中
公開翌日4時まで

JSでバイナリを扱う 最終回 画像のバイト列を操作する

シリーズ最終回は、初回から予告してきた「選んだ画像をグレースケールにするデモ」を完成させます。URL.createObjectURL()による画像の表示と、Canvasから取り出したピクセルのバイト列の書き換えを組み合わせます。最後にシリーズ全体を振り返ります。

発行

著者 藤田 智朗 フロントエンド・エンジニア
JSでバイナリを扱う シリーズの記事一覧

はじめに

このシリーズでは「JavaScriptでバイナリを扱う」をテーマに、ユーザーが端末から選んだ画像ファイルを読み込み、グレースケール(白黒)に加工して表示するデモを完成させるための材料を、3回にわたって揃えてきました。ここまでの流れをまずはおさらいしましょう。

  • 第1回:すべてのファイルは0255の数値が並んだバイト列であり、「テキスト」と「バイナリ」はその見かたの違いにすぎない――という出発点を確認しました
  • 第2回:バイト列を実際に読み書きする道具として、ArrayBuffer(箱)とTypedArrayDataView(窓)を学びました
  • 第3回BlobFileを入り口に、ユーザーが選んだ実際のファイルからバイト列を取り出せるようになりました

最終回ではこの3つを組み合わせて、第1回から予告してきたデモを完成させます。選んだ画像をFileとして受け取り(第3回)、そのピクセルをTypedArrayの窓でバイト列として読み書きします(第2回)。そして「画像も結局は0255の数値の並びなのだから、数値を書き換えれば見た目が変わるはずだ」――第1回で説明した見立てを、画像のピクセルのバイト列を取り出し、その数値を実際に書き換えながら確かめていきます。

ただし、これまでに揃えた材料だけでは、まだ足りないピースが2つあります。1つは、受け取ったFileを画面に表示する手段。もう1つは、画像のピクセルをバイト列として取り出す手段です。まずはこの2つを、URL.createObjectURL()CanvasgetImageData()という新しい道具で埋めていきます。

URL.createObjectURL():Blobを画面に表示する

では、ユーザーが選んだ画像をプレビュー表示することから始めましょう。ここで1つ問題があります。前回見たとおり、選択された画像はFileオブジェクトとして手元にありますが、<img>src属性が受け取るのはURLです。手元のオブジェクトを、どうやってsrcに渡せばよいのでしょうか。

これを解決するのがURL.createObjectURL()です。BlobFileを渡すと、それを指す一時的なURLを生成してくれます。生成されたURLは、<img>srcなどにそのまま渡せます。なお、コード中のimgはプレビュー用の<img>要素、canvasは次の節でピクセルの取り出しに使う<canvas>要素です。

選択された画像をプレビューする

const img = document.getElementById('preview');
const canvas = document.getElementById('canvas');

const url = URL.createObjectURL(file);
img.src = url;

img.addEventListener('load', () => {
  // 使い終わったら解放する
  URL.revokeObjectURL(url);
  // 画像のグレースケール化(このあと実装していきます)
  convertToGrayscale();
});

loadイベントの中で呼び出しているconvertToGrayscale()は、このあとの節で少しずつ実装していく、グレースケール化の本体です。

このURLはサーバーにアップロードして得られるものではなく、ブラウザの内部だけで通用する、Blobへの参照です。ネットワークを介さないので、大きな画像でもすぐに表示できます(ただし、非常に大きな画像では、デコードや描画そのものに時間がかかることはあります)。

注意したいのは、使い終わったURLをURL.revokeObjectURL()で解放するのを忘れないことです。createObjectURL()で作ったURLは、明示的に解放するかページを閉じるまで、Blobへの参照を保持し続けます。画像を次々に扱うページではその分メモリを圧迫するので、表示が済んだら解放する習慣をつけておきましょう。

コラム:データURLとの違い

似た道具として、データURLを見たことがある人もいるでしょう。data:image/png;base64,iVBORw0KGgo...のような形式のURLで、FileReaderというAPIのreadAsDataURL()メソッドを使うとBlobFileから作れます。こちらも<img>srcにそのまま渡せるので、一見すると同じ用途の道具に思えます。

しかし、中身はまったくの別物です。データURLは、ファイルのバイト列をBase64という方式で、テキストとして安全に持ち運べる文字列へ符号化し、URLそのものに埋め込んだコピーです。同じバイト列を別の見かたで読んでいるのではなく、新しい文字列への変換が行われている点に注意してください。符号化後の文字列の長さは、元のバイト列の約1.3倍に膨らみます。

一方、URL.createObjectURL()が返すのはblob:https://...で始まる短い文字列だけです。ファイル全体を文字列に変換する必要がなく、URLは元のBlobを指し示す参照にすぎません。データURLより低い処理コストで表示できるのはこのためです。データURLが向いているのは、データをURL文字列として持ち運びたい場面――たとえばHTMLやCSSに画像を直接埋め込みたいとき――に限られます。手元のBlobをいますぐ表示したいだけなら、オブジェクトURLを選びましょう。

画像のピクセルを取り出す

表示できたら、次は加工です。バイト列を扱える強みがもっともはっきり出るのが、画像のピクセル操作です。先ほどプレビューした<img>Canvasに描き写すと、getImageData()ピクセルの生データを取り出せます。

ここからの処理は、先ほどloadイベントから呼び出していたconvertToGrayscale()の中に書いていきます。関数にまとめて、読み込みが完了してから実行するようにしているのは、このあと使う画像の実サイズ(naturalWidthnaturalHeight)が、読み込みの完了を待たないと正しい値にならないためです。

ピクセルデータを取り出す

function convertToGrayscale() {
  // Canvasのサイズを画像の実サイズに合わせる
  canvas.width = img.naturalWidth;
  canvas.height = img.naturalHeight;

  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);

  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  console.log(imageData.data);
  // コンソールへの出力例(1000×1000ピクセルの画像の場合)。4000000は要素数
  // [ ]内は要素の中身。R・G・B・Aが繰り返し並ぶ(実際の値は画像により異なる)
  // Uint8ClampedArray(4000000) [ 255, 128, 64, 255, 254, 127, 63, 255, ... ]
  //                              R    G    B   A    R    G    B   A

  // このあと、グレースケールへの変換を実装します
}

関数の最初の2行では、Canvasのサイズを画像の実サイズ(naturalWidthnaturalHeight)に合わせています。Canvasには既定のサイズ(300×150)があるため、これを忘れると画像の一部しか描き写されません。そのうえでdrawImage()で画像を描き、getImageData()でピクセルデータを取り出しています。

ここで返ってくるimageData.dataの型はUint8ClampedArrayです。このシリーズでは初めて登場した型ですが、これは第2回で見たTypedArrayファミリーの一員、つまりバイト列に被せる「窓」の仲間です。中身は1ピクセルあたりR・G・B・A(赤・緑・青・不透明度)の4バイトが延々と並んだバイト列です。コード中のコメントに示した(4000000)という要素数は、100万ピクセル×4バイト分の値が並んでいることを表しています。

Uint8ClampedArrayUint8Arrayとよく似ていますが、代入した値の扱いが2つの点で違います。1つ目は、範囲外の値の扱いです。Uint8Arrayは、範囲を超えた値を256で割った余りに変換します。たとえば260を代入すると4になり、「限界を少し超えるほど明るい色」のつもりが、ほぼ真っ黒に化けてしまいます。一方のUint8ClampedArrayは、0未満なら0に、255超なら255に丸めます(clampは「挟み込んで範囲内に収める」という意味です)。明るさの計算で値が多少はみ出しても「限界まで明るい」255に収まるだけで、色は破綻しません。

2つ目は、小数の扱いです。Uint8Arrayが小数部を切り捨てるのに対し、Uint8ClampedArrayはもっとも近い整数に丸めます。このあとのグレースケール変換で計算する明るさは、まさに小数になる値です。ピクセルの値を計算で書き換える用途には、Uint8ClampedArrayの挙動のほうが合っているのです。

1つ注意したいのは、getImageData()が返すのはCanvasのピクセルのコピーだという点です。コピーであるimageData.dataをいくら書き換えても、Canvasの表示は変わりません。書き換えた結果を反映するには、このあと出てくるputImageData()Canvasへ書き戻す必要があります。

画像の実体がバイト列だということは、そのバイト列を書き換えれば画像を加工できるはずです。試してみましょう。

デモの完成:画像をグレースケールにする

材料が揃いました。デモを完成させましょう。R・G・Bが同じ値のピクセルは、必ず灰色になります(#808080のような、同じ値が並んだ色指定を思い出してください)。そこで、各ピクセルのR・G・Bを「そのピクセルの明るさ」という1つの値に揃えます。そうすれば色みが消え、画像はグレースケール(白黒)になります。先ほどのconvertToGrayscale()の続きとして、変換の処理を書き加えましょう。

グレースケールに変換する

function convertToGrayscale() {
  // ...ここまでは先ほどのコードのとおり

  const data = imageData.data;

  for (let i = 0; i < data.length; i += 4) {
    const r = data[i];
    const g = data[i + 1];
    const b = data[i + 2];
    // 人間の目の感度に合わせた重みづけで明るさを求める
    const gray = 0.299 * r + 0.587 * g + 0.114 * b;
    data[i] = gray;
    data[i + 1] = gray;
    data[i + 2] = gray;
    // data[i + 3] はアルファ値なのでそのまま
  }

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

1ピクセルは4バイトなので、ループは4バイトずつ進みます。各ピクセルでR・G・Bの3バイトを読み、明るさを計算して同じ値に揃え、最後にputImageData()Canvasへ書き戻しています。

明るさの計算に重みが付いているのは、人間の目が緑をもっとも明るく、青をもっとも暗く感じるためです。係数を見ると、緑の0.587がもっとも大きく、青の0.114がもっとも小さくなっており、この感度の差をそのまま反映しています。また、3つの係数は合計すると1になるので、計算結果が0255の範囲からはみ出すこともありません。R・G・Bの単純な平均でも白黒にはなりますが、この重みづけのほうが自然な明るさに見えます。

単純平均と重みづけの違いは、彩度の高い色でもっともはっきり出ます。純色の赤・緑・青を、それぞれの方法でグレースケール化して見比べてみましょう。

単純平均(中段)では、目で見ると明るさの違う3色が、すべて同じ85のグレーに潰れてしまいます。重みづけあり(下段)では7615029と分かれ、緑がもっとも明るく青がもっとも暗いという、目で見た印象どおりの明暗が保たれます。

逆に、R・G・Bの値が互いに近い、彩度の低い色では2つの計算結果もほぼ同じになります。そのため、彩度があまり高くない写真やイラストでは、どちらの方法で変換しても見た目はほとんど変わりません。

なお、次のデモでは、変換前後を見比べられるように処理を2つに分けています。Canvasへの描き写しは読み込み時のdrawToCanvas()で行い、convertToGrayscale()はボタンを押したときに実行します。細かな違いはソースコードを参照してください。

特別なライブラリは何も使っていません。やっていることは、第1回で「すべてはバイト列」と言ったことそのものです。画像という一見特別なものも、Canvas上にデコードしてしまえば0255の数値の並びであり、その数値をループで書き換えれば加工できるのです。

コラム:GIFでもPNGでもJPEGでも動くのはなぜか

このデモは、GIF・PNG・JPEGなど、ブラウザが表示できる画像形式ならどれを選んでも動きます。といっても、これらのファイルのバイト列の構造が似ているわけではありません。第3回で覗いたPNGには固有のシグネチャや、幅・高さの決まった置き場所がありましたが、あれはPNGだけの構造です。JPEGは非可逆圧縮、GIFはパレット方式と、同じ画像でも「バイト列への符号化のしかた」は形式ごとにまったく違います。

それでもデモが形式を問わないのは、グレースケール変換が触っているのがファイルのバイト列ではなく、デコード後のピクセルのバイト列だからです。<img>に表示した時点でブラウザが形式を判別してデコードし、getImageData()は元の形式が何であれ、常に「1ピクセルあたりR・G・B・Aの4バイト」という同じ形に揃ったバイト列を返します。形式ごとの違いはCanvasが吸収してくれるので、その先のループは形式を意識しなくてよいのです。

なお、アニメーションGIFは最初の1フレームだけがCanvasに描かれるため、変換すると静止画になります。また、PNGやGIFの透過は、ループでアルファ値(data[i + 3])を触っていないため、そのまま保たれます。

シリーズのまとめ

4回にわたったシリーズを振り返りましょう。

  • 第1回:すべてのファイルはバイト列であり、「テキスト」と「バイナリ」はその見かたの違いにすぎない
  • 第2回:バイト列はArrayBuffer(箱)とTypedArrayDataView(窓)で扱う
  • 第3回:選択されたファイルはFileBlobとして届き、arrayBuffer()でバイト列を取り出せる
  • 第4回:画像のピクセルもバイト列であり、直接書き換えて加工できる

最終回のグレースケールのデモでは、Fileとして画像を受け取り(入手経路)、TypedArrayの窓でピクセルのバイト列を読み書きし(型)、0255の数値の並びを書き換えることで画像そのものを変化させました(概念)。第3回の16進ダンプと合わせて、バラバラに見えた知識が一本につながったのを感じてもらえたなら幸いです。

ここから先には、実際のファイルフォーマットを解析する、ReadableStreamで大きなデータを少しずつ処理する、WebAssemblyとArrayBufferをやり取りする、といった応用が広がっています。どれも出発点は同じ「バイト列」です。今回つかんだ感覚を土台に、ぜひ次の一歩へ進んでみてください。