【JS体操】第5問「画像からアスキーアートを生成しよう」解説

こんにちは!面白プロデュース事業部のおばらです。

またまた大変お待たせいたしました!!
『JS体操』第5問「画像からアスキーアートを生成しよう」の解説記事をお届けいたします。

この第5問は YAPC::Hakodate 2024 の開催に合わせ、同じくカヤック主催のPerl版コードゴルフ大会『Perlbatross』との同時開催となったスペシャル版です。

yapcjapan.org



ノベルティのチラシも用意し、イベント内で告知させていただきました。
運営のみなさま、『Perlbatross』と『JS体操』に参加して頂いたみなさま、ありがとうございます!



もし第5問まだ挑戦できていなかった!というひとは以下よりぜひ。

hubspot.kayac.com




目次


1位の回答

1位は halwhite さんでした!おめでとうございます!!

export default(b,t=document.createElement`canvas`.getContext`2d`)=>fetch(`test-cases/${t.drawImage(b,0,0),t.getImageData(8,32,1,1).data[0]?'kaya':'yap'}c.txt`).then(r=>r.text())

1ピクセルのみをチェックするというハック的な手法ですね!
UNBELIEVABLE♪




社内で元々想定していた最短文字数の回答

export default(t,W=128,c=new OffscreenCanvas(W,W).getContext`2d`)=>c.getImageData(~~c.drawImage(t,0,0,W,64),0,W,64).data.reduce((a,p,i)=>a+=i%4?~i%512?'':`
`:'#`'[p>>7],'')

想定回答は真面目に変換するロジックでした!




解説

① canvas の生成をなるべく短く書く

const cvs = document.createElement('canvas');

元のコードでは、上記のように canvas タグを生成しています。
ここで、この canvas タグは DOM ツリーにはその後も挿入されないことに注目しましょう。
つまり、画面上に表示するためではなく、単に内部的に(≒画面外で)画像処理のために生成しているに過ぎないのです。
ということは、画面外、言い換えるとオフスクリーン専用の OffscreenCanvas でも良さそうですね!

また、今回必要な canvas のサイズは既知ですから以下で OK です。

const cvs = new OffscreenCanvas(128, 128);



② 2行の平均をとる処理を canvas.drawImage() に委ねる

aa.js の以下の部分に注目してください

  //
  //  ピクセルデータを文字に変換
  //  1文字が縦長なので、横1px ✕ 縦2px の2ピクセルを1文字で表現する
  //

このロジックを実現するために、元のコードでは泥臭く平均を計算しています。
でもこれ、drawImage する際に縦方向のみを 1/2 に縮小すれば良さそうではないでしょうか?
そう、(本問題では)それで代替できるのです。 drawImage() で拡縮して描画した際の結果は(通常は)環境依存であることに注意してください。本問題では環境ごとに差異が出ないようなテストケースにしています。



③ 元々が白黒画像なので、RGB の単純平均を取る必要はない

const r0 = rgbaArray[i + 0] / 0xFF; // 1行目の赤チャンネル(0〜1に正規化)
const g0 = rgbaArray[i + 1] / 0xFF; // 1行目の緑チャンネル(0〜1に正規化)
const b0 = rgbaArray[i + 2] / 0xFF; // 1行目の青チャンネル(0〜1に正規化)
...
const gray0 = (r0 + g0 + b0) / 3; // 1行目の赤緑青チャンネルの単純平均

元のコードでは上記のようにRGBの単純平均を計算してますが、RGB いずれかの値を参照すればよいですね!

ここまでの知識で、以下まで短くできます。

export default function aa(bmp) {
  //
  //  キャンバス作成とコンテキスト取得
  //
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');

  //
  //  画像をキャンバスに描画
  //
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );

  //
  //  ピクセルデータ取得
  //
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;

  //
  //  ピクセルデータを文字に変換
  //  1文字が縦長なので、横1px ✕ 縦2px の2ピクセルを1文字で表現する
  //
  const rows = [];

  for (let y = 0; y < 64; y += 1) {
    const row = [];

    for (let x = 0; x < 128; x += 1) {
      const i = ((128 * (y + 0)) + x) * 4;
      const gray = rgbaArray[i + 0] / 0xFF;

      if (gray < 0.5) {
        row.push('#');
      } else {
        row.push('`');
      }
    }

    rows.push(row.join(''), '\n');
  }

  return rows.join('');
}




④ if 文での分岐を配列の添字アクセスで代替する

以下の部分に注目してください。

const gray = rgbaArray[i + 0] / 0xFF;

if (gray < 0.5) {
  row.push('#');
} else {
  row.push('`');
}


まず / 0xFFif 文の条件部分に移動します

const gray = rgbaArray[i + 0];

if (gray < 0.5 * 0xFF) {
  row.push('#');
} else {
  row.push('`');
}


0.5 * 0xFF0.5 * 255、つまり 127.5 ですから、

const gray = rgbaArray[i + 0];

if (gray < 127.5) {
  row.push('#');
} else {
  row.push('`');
}


ここで、条件分岐を配列の添字アクセスで代替してしまいます。

const gray = rgbaArray[i + 0];

row.push('`#'[gray < 127.5 ? 1 : 0]);


更にシンプルにして、

const gray = rgbaArray[i + 0];

row.push('`#'[+(gray < 127.5)]);


本問題ではビット演算を使用してさらに短くできそうです。

const gray = rgbaArray[i + 0];

row.push('#`'[gray >> 7]);


整理すると

row.push('#`'[rgbaArray[i] >> 7]);


現状でここまで短くなっているはずです!

export default function aa(bmp) {
  //
  //  キャンバス作成とコンテキスト取得
  //
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');

  //
  //  画像をキャンバスに描画
  //
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );

  //
  //  ピクセルデータ取得
  //
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;

  //
  //  ピクセルデータを文字に変換
  //  1文字が縦長なので、横1px ✕ 縦2px の2ピクセルを1文字で表現する
  //
  const rows = [];

  for (let y = 0; y < 64; y += 1) {
    const row = [];

    for (let x = 0; x < 128; x += 1) {
      const i = ((128 * (y + 0)) + x) * 4;

      row.push('#`'[rgbaArray[i] >> 7]);
    }

    rows.push(row.join(''), '\n');
  }

  return rows.join('');
}




⑤ for ループを配列のメソッドで代替する

以下のループ部分を短くしましょう。

  const rows = [];

  for (let y = 0; y < 64; y += 1) {
    const row = [];

    for (let x = 0; x < 128; x += 1) {
      const i = ((128 * (y + 0)) + x) * 4;

      row.push('#`'[rgbaArray[i] >> 7]);
    }

    rows.push(row.join(''), '\n');
  }

  return rows.join('');


こう直せますね!

  const rows = Array(128 * 64)
    .fill()
    .map(
      (_, i) => {
        const isEndOfLine = (i + 1) % 128 === 0;
        
        return '#`'[rgbaArray[i * 4] >> 7] + (isEndOfLine ? '\n' : '');
      }
    );


文字数をチェックすると、、

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = Array(128 * 64)
    .fill()
    .map(
      (_, i) => {
        const isEndOfLine = (i + 1) % 128 === 0;
        
        return '#`'[rgbaArray[i * 4] >> 7] + (isEndOfLine ? '\n' : '');
      }
    );

  return rows.join('');
}

GOOD♪




⑥ 新規に作成した配列ではなく、ピクセルデータが格納されている Uint8ClampedArray のメソッドを使う

Array(128 * 64) で新たにからの配列データを作るのをやめましょう。
つまりこうです。

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = [...rgbaArray].map(
    (pixel, i) => {
      const isEndOfLine = (i + 1) % (128 * 4) === 0;
      const isRedPixel = i % 4 === 0;
      
      return (
        (isRedPixel ? '#`'[pixel >> 7] : '') +
        (isEndOfLine ? '\n' : '')
      );
    }
  );

  return rows.join('');
}



ここで、rgbaArray.map ではなく [...rgbaArray].map としていることに注目してください。
TypedArraymap() の戻り値は、元の TypedArray と同じ型になるからです。

rgbaArray.xxxx と直接書けるように、map() ではなく reduce() にしてみましょう。 例えばこうです。

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = rgbaArray.reduce(
    (result, pixel, i) => {
      const isEndOfLine = (i + 1) % (128 * 4) === 0;
      const isRedPixel = i % 4 === 0;
      
      return result + (
        (isRedPixel ? '#`'[pixel >> 7] : '') +
        (isEndOfLine ? '\n' : '')
      );
    },
    ''
  );

  return rows;
}




⑦ 整理していく

さぁ、ここからどんどん短くしていきます!

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = rgbaArray.reduce(
    (result, pixel, i) => result + (
      (i % 4 ? '' : '#`'[pixel >> 7]) +
      ((i+1) % 512 ? '' : '\n')
    ),
    ''
  );

  return rows;
}



ふたつの三項演算子をまとめちゃいます。

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = rgbaArray.reduce(
    (result, pixel, i) => result + (
      i % 4 ? ((i+1) % 512 ? '' : '\n') : '#`'[pixel >> 7]
    ),
    ''
  );

  return rows;
}



(i + 1)~i にして1文字減らしましょう。

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = rgbaArray.reduce(
    (result, pixel, i) => result + (
      i % 4 ? (~i % 512 ? '' : '\n') : '#`'[pixel >> 7]
    ),
    ''
  );

  return rows;
}



result + (...)result += ... にして丸括弧を省略できるようにします。

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = rgbaArray.reduce(
    (result, pixel, i) => result += i % 4 ? (~i % 512 ? '' : '\n') : '#`'[pixel >> 7],
    ''
  );

  return rows;
}



どんどん整理しちゃいましょう!

export default bmp => {
  const ctx = new OffscreenCanvas(128, 64).getContext('2d');
  ctx.drawImage(bmp, 0, 0, 128, 64);
  return ctx.getImageData(0,0,128,64).data.reduce(
    (result, pixel, i) => result += i % 4 ? (~i % 512 ? '' : '\n') : '#`'[pixel >> 7],
    ''
  );
};

GREAT♪



export default (bmp, ctx = new OffscreenCanvas(128, 64).getContext('2d')) => ctx.getImageData(
  ~~ctx.drawImage(bmp, 0, 0, 128, 64), 0, 128, 64
).data.reduce(
  (result, pixel, i) => result += i % 4 ? (~i % 512 ? '' : '\n') : '#`'[pixel >> 7],
  ''
);



最後に余計なホワイトスペースを消して、、

export default(t,W=128,c=new OffscreenCanvas(W,W).getContext`2d`)=>c.getImageData(~~c.drawImage(t,0,0,W,64),0,W,64).data.reduce((a,p,i)=>a+=i%4?~i%512?'':`
`:'#`'[p>>7],'')

できた!!!
UNBELIEVABLE♪




おまけ

アスキーアート楽しいですよね!
私はアスキーアートが好きすぎて、昔こんなツールも作ったことがあります。
ぜひ遊んでみてください笑



https://aagen.tsmallfield.com/




まとめ

最後まで読んでいただきありがとうございます。
そして挑戦してくださったみなさま、ありがとうございました。

実は今回の第5問で『JS体操』は一旦区切りとなります。

もともとは JavaScript の仕様の理解とプログラミング的思考力を鍛えるために始めた社内勉強会『JS体操』。
それをぜひ社外の方にも遊んでいただこうと始まった本シリーズ、全5問いかがでしたでしょうか?

もしこの『JS体操』という文化がいろんな会社に根付き、そして独自に発展していったら幸いです。
『TS体操』『GO体操』みたいにスケールするのも面白そうですね。

今まで参加&応援してくださったみなさま、本当にありがとうございました。




『JS体操』の情報を受け取ろう

登録フォーム

「解説ブログ」や「JS体操の問題」のお知らせが気になる方は以下のページにてご登録ください。

hubspot.kayac.com

技術部公式 X アカウント

面白法人カヤック技術部公式 X アカウント @kayac_tech でも随時情報を発信します。

カヤック技術部公式 X アカウント @kayac_tech


『JS体操』過去問一覧

『JS体操』の過去問、まだ挑戦していない!という方はぜひ。

hubspot.kayac.com hubspot.kayac.com hubspot.kayac.com hubspot.kayac.com hubspot.kayac.com


お知らせ

先日、Perl のコードゴルフコンテストもカヤック主催で開催されました。
こちらも面白いのでぜひご覧ください!

techblog.kayac.com

そして、カヤックではコードゴルフが大好きな新卒&中途エンジニアも募集しています!

www.kayac.com www.kayac.com