猫の手ツール

Step 3: Core Technology

16. Canvas API の使い方:
画像をスタンプのように
扱う技術

Canvasは、読み込んだ画像をピクセルで配置し、別の画像として書き出すために使えます。証明写真ツールの実際の2列×3行配置を確認した後、愛猫の写真を4枚並べる説明用サンプルを自分のPCで動かします。入力の読み込みからPNG保存までの完全コードも掲載します。

1. Canvas API は「プログラムで描く画用紙」

HTML5から導入された Canvas API は、ブラウザでグラフィックを動的に描画するための非常に強力なインターフェースです。通常のHTML要素(DOM)が「文書の構造」を定義するのに対し、Canvasは「ピクセルの集まり」を直接操作します。

猫の手ツールの証明写真作成機能では、画面に表示されないメモリ上の仮想Canvasを作成し、そこにユーザーが選択した画像をトリミングして配置しています。これはいわば、暗室で印画紙に写真を焼き付けるような作業を、JavaScriptの命令だけで完結させているのです。

2. drawImage() 命令:最強のスタンプ

Canvasで画像を扱う際、最も多用するのが drawImage() メソッドです。このメソッドは引数の数によって役割が変わりますが、最もリッチな「9つの引数」を持つバージョンを使うことで、「トリミング」と「リサイズ配置」を一度に実行できます。

引数の意味を解剖する

ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)

  • sx, sy, sWidth, sHeight:元画像のどの部分を切り抜くか(Source)
  • dx, dy, dWidth, dHeight:画用紙のどこに、どのサイズで描画するか(Destination)

【具体例】プリントシートに2枚並べるコード

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2Dを利用できません');
// userImageは読み込み済み画像、userWidth/Heightは元の画素数

// 画用紙のサイズを設定(3:4の説明用キャンバス。A4比率ではない)
canvas.width = 1200;
canvas.height = 1600;

// 1枚目を左上に配置(余白100px、幅400px)
ctx.drawImage(userImage, 0, 0, userWidth, userHeight, 100, 100, 400, 500);

// 2枚目をその横に配置(X座標を550pxにずらす)
ctx.drawImage(userImage, 0, 0, userWidth, userHeight, 550, 100, 400, 500);

3. 実装の落とし穴:非同期処理とボケ問題

Canvasを扱う上で、初心者が必ずと言っていいほどハマるポイントが2つあります。

1. 画像の読み込み待ち(Image.onload)

JavaScriptで new Image() を作成した直後に描画命令を出しても、画像は表示されません。データの読み込みが完了する前に「スタンプ」を押そうとしているからです。必ず onload イベントを待機するか、現代的な開発では Promise を使って非同期制御を行う必要があります。

2. 高解像度ディスプレイでの「ボケ」

Retinaディスプレイなどの高画素密度環境では、Canvasがぼやけて見えることがあります。これは、ブラウザの論理ピクセルと画面の物理ピクセルに乖離があるためです。

解決策として、window.devicePixelRatio に合わせてCanvas自体の解像度を2倍、3倍に設定し、CSSで表示サイズを調整するテクニックが必要です。これは画面上のプレビューを鮮明にするための調整です。保存画像の画素数はcanvas.width/heightで決まり、印刷用の寸法から求めた出力Canvasへ端末のdevicePixelRatioを無条件に掛ける必要はありません。

4. 実務での活用シーン:インフラ図からバナー生成まで

Canvas APIのスキルは、画像加工ツール以外でも非常に重宝されます。

  • 動的なOGP生成: ブログ記事のタイトルを画像に合成して、SNSシェア用のバナーを自動生成する。
  • ネットワーク図の描画: インフラ構成をコードから動的に視覚化する。座標計算の感覚は、ネットワークトポロジを描く際の論理的な思考と非常に似ています。
  • データビジュアライゼーション: 多数の点や図形を描画する。DOMとの性能差は描画量や更新方法によるため、実際のデータで測定する。

写真を引き伸ばさずに枠へ収める

上の2枚配置例は、元画像と400×500pxの枠で縦横比が違うと写真が変形します。比率を保って中央を切り抜くには、元画像側の切り抜き範囲を計算します。以下は読み込み済みのHTMLImageElementと描画先ctxを受け取る関数です。

function drawCover(ctx, image, x, y, width, height) {
  const iw = image.naturalWidth;
  const ih = image.naturalHeight;
  if (!iw || !ih || width <= 0 || height <= 0) return;
  const scale = Math.max(width / iw, height / ih);
  const sw = width / scale;
  const sh = height / scale;
  ctx.drawImage(image, (iw - sw) / 2, (ih - sh) / 2, sw, sh,
    x, y, width, height);
}

この方法は中央基準なので、顔や文字が端にある写真では切れてしまうことがあります。利用者が切り抜き位置を確認できるプレビューが必要です。画像の読み込みに失敗した場合や、外部画像のCORS制約で書き出せない場合も、描画範囲の計算とは別に扱います。

証明写真の本番は、中央寄せした2列×3行の座標を計算する

履歴書写真メーカーの印刷モードは1051×1500 pxのCanvasへ、354×472 pxの画像を6枚配置します。写真群の合計幅・高さを先に計算し、余った領域の半分を開始位置にします。

const w = 354, h = 472;
const hGap = 60, vGap = 40;
const startX = (canvas.width - (w * 2 + hGap)) / 2;
const startY = (canvas.height - (h * 3 + vGap * 2)) / 2;
for (let row = 0; row < 3; row++) {
  for (let col = 0; col < 2; col++) {
    const x = startX + col * (w + hGap);
    const y = startY + row * (h + vGap);
    ctx.drawImage(originalCroppedCanvas, x, y, w, h);
  }
}

本番の枠線描画を省いた抜粋です。originalCroppedCanvasは切り抜き済み画像です。証明写真の制作解説で出力仕様を確認できます。Canvasの画素数と、印刷された写真のmmは同じ情報ではないため、保存しただけで実寸が保証されるとは考えません。

愛猫を4枚並べ、PNGを保存する完全サンプル

愛猫写真の動作サンプルを開く / 完全サンプルZIPを保存する

ZIPをすべて展開し、index.html・app.js・cat.jpg・README.txtがあるフォルダーでターミナルを開きます。Node.jsが未導入ならNode.js公式サイト(別タブで開く)のLTS版を導入してターミナルを開き直します。次を1行ずつ実行し、サーバーを開いたままにします。

node --version
npm.cmd --version
npx.cmd --yes http-server . -p 8080 -c-1

ブラウザでhttp://localhost:8080/を開きます。macOS・Linuxはnpm・npxへ読み替えます。8080が使用中ならコマンドとURLを8081へ変更し、終了はCtrl+Cです。file://では起動しません。初回のHTTPサーバー取得には通信が必要です。

実行ボタンを押し、入力画像と4枚配置した結果を見比べます。保存リンクからPNGを保存します。JSONに入力の元寸法・縮小後寸法、出力960×720 px、実際に生成したBlobの容量が出ます。4枚配置は教材の設定で、本番の証明写真の6枚配置とは別です。

愛猫の写真を読み込み、Canvasで4枚配置してPNGを生成した説明用サンプルの実行画面
Windows・Chrome 154で、既存の愛猫写真を読み込み、4枚配置とPNG生成を実際に実行した画面。4枚配置は説明用で、本番の証明写真レイアウトや印刷品質を示すものではありません。

教材のdrawCover()は入力Canvasの中央を縦横比を保って切り抜き、drawGrid()が4つの枠へ描画します。画像の端にある対象が切れる場合は、枠の比率と切り抜き位置を確認します。入力の段階はFile APIの記事、書き出し完了の待機はasync/awaitの記事で詳しく読めます。

配置と保存を実行して確認する

Windows・Chrome 154で800×589 pxの愛猫写真を実行し、960×720 pxのPNGを生成しました。4つの枠は各444×324 px、外側の余白と間隔は24 pxです。保存リンクからcat_grid.pngを保存し、保存ファイルの寸法・764,899バイトという容量が、画面のBlob情報と一致することも確認しました。この入力とブラウザでの結果です。

完全コード:HTMLとJavaScript

以下はダウンロードZIPと同じソースです。HTML内に通常のCSSを含み、外部SDK・Tailwind・MediaPipeを必要としません。cat.jpgを同じフォルダーに置いて起動します。

index.html:画面とCSSの完全コード
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
  <meta name="robots" content="noindex">
  <title>File API → Canvas → async/await:猫写真の画像処理サンプル</title>
  <style>
    :root{font-family:system-ui,sans-serif;color:#17243c;background:#eef2f8}*{box-sizing:border-box}body{margin:0}main{max-width:1100px;margin:auto;padding:24px 20px}h1{font-size:clamp(1.6rem,4vw,2.25rem);line-height:1.5}p,li{line-height:1.8}section,figure{margin:18px 0;background:white;border:1px solid #dbe1eb;border-radius:14px;padding:18px}button,input{font:inherit}button,.download{display:inline-block;background:#4338ca;color:white;border:0;border-radius:8px;padding:12px 18px;text-decoration:none;cursor:pointer}button:disabled{opacity:.55;cursor:wait}.controls{display:flex;align-items:center;flex-wrap:wrap;gap:12px}label{display:block;margin:10px 0;font-weight:650}input{max-width:100%}#status{border-left:4px solid #4338ca;background:white;padding:14px;min-height:58px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}canvas{display:block;width:100%;height:auto;background:#f8fafc;border-radius:8px}figcaption{font-weight:650;margin-bottom:12px}small{color:#53637d}pre{background:#12203a;color:#e0e8ff;padding:16px;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:420px;overflow:auto}a{color:#4338ca}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #e99a23;outline-offset:3px}.download{margin-top:12px}.download[hidden]{display:none}@media(max-width:640px){main{padding:18px 12px}.grid{grid-template-columns:1fr}section,figure{padding:14px}}
  </style>
</head>
<body><main>
  <p><a href="./README.txt">起動手順を開く</a></p>
  <h1>File API → Canvas → async/await<br>愛猫の写真を読み込み、4枚並べてPNGを保存</h1>
  <p>猫の手ツールの画像読み込み・配置・保存を学ぶための説明用サンプルです。本番の証明写真ツールやAIの実装をそのまま抜き出したものではありません。</p>
  <section id="select"><h2>1. 入力を選ぶ</h2>
    <p>標準はプロジェクト内の愛猫の写真です。入力を送信する処理はありません。内蔵画像はこのHTTPサーバーから取得します。</p>
    <label for="image-file">自分の画像で試す場合(JPEG・PNG・WebP、20 MB以下)</label>
    <input id="image-file" type="file" accept="image/jpeg,image/png,image/webp">
    <div class="controls"><button id="run-button">画像を読み込み、4枚配置する</button><button id="reset-button">内蔵の猫写真に戻す</button></div>
    <p><small>画像を選んだだけでは実行しません。HEIC変換・カメラ・MediaPipeは、このサンプルには含みません。</small></p>
  </section>
  <p id="status" role="status" aria-live="polite">準備できました。ボタンを押してください。</p>
  <div class="grid" id="result">
    <figure><figcaption>2. Fileから読み込んだ入力(長辺960 px以内)</figcaption><canvas id="input-canvas" width="800" height="589" role="img" aria-label="実行後に選んだ入力画像を表示します"></canvas></figure>
    <figure><figcaption>3. Canvasで4枚配置したPNG(960×720 px)</figcaption><canvas id="result-canvas" width="960" height="720" role="img" aria-label="実行後に4枚配置した結果画像を表示します"></canvas><a id="download-link" class="download" download="cat-grid.png" hidden>生成したPNGを保存する</a></figure>
  </div>
  <section id="output"><h2>実際に処理した条件とデータ</h2><pre id="json-output">まだ実行していません。</pre>
    <p><small>Object URLは入力のデコード後と出力の差し替え時に解放します。画像の原本やOS上のファイルは変更しません。保存したPNGに印刷時の実寸は保証しません。</small></p>
  </section>
</main><script type="module" src="./app.js"></script></body></html>
app.js:画像処理の完全コード
const runButton = document.querySelector('#run-button');
const resetButton = document.querySelector('#reset-button');
const picker = document.querySelector('#image-file');
const status = document.querySelector('#status');
const inputCanvas = document.querySelector('#input-canvas');
const outputCanvas = document.querySelector('#result-canvas');
const download = document.querySelector('#download-link');
const json = document.querySelector('#json-output');
let busy = false;
let outputUrl = null;

function setBusy(value) {
  busy = value;
  runButton.disabled = resetButton.disabled = picker.disabled = value;
}

function releaseOutput() {
  download.hidden = true;
  download.removeAttribute('href');
  if (outputUrl) URL.revokeObjectURL(outputUrl);
  outputUrl = null;
}

// 内蔵写真を取得して、メモリ上のFileを作ります。利用者の画像の送信ではありません。
async function getInputFile() {
  if (picker.files[0]) return picker.files[0];
  const response = await fetch('./cat.jpg');
  if (!response.ok) throw new Error('内蔵の猫写真を取得できません。cat.jpgが同じフォルダーにあるか確認してください');
  const blob = await response.blob();
  return new File([blob], 'cat.jpg', { type: blob.type || 'image/jpeg' });
}

// FileもBlobなのでObject URLへ渡せます。読み込めた画素をCanvasへ移してからURLを解放します。
async function readFileIntoCanvas(file) {
  if (file.size > 20 * 1024 * 1024) throw new Error('画像は20 MB以下にしてください');
  const url = URL.createObjectURL(file);
  try {
    const image = new Image();
    image.src = url;
    await image.decode();
    const originalWidth = image.naturalWidth, originalHeight = image.naturalHeight;
    const scale = Math.min(1, 960 / Math.max(originalWidth, originalHeight));
    inputCanvas.width = Math.max(1, Math.round(originalWidth * scale));
    inputCanvas.height = Math.max(1, Math.round(originalHeight * scale));
    inputCanvas.getContext('2d').drawImage(image, 0, 0, inputCanvas.width, inputCanvas.height);
    inputCanvas.setAttribute('aria-label', `${file.name}の入力画像`);
    return { originalWidth, originalHeight, width: inputCanvas.width, height: inputCanvas.height };
  } finally { URL.revokeObjectURL(url); }
}

// 元画像の中央を、縦横比を保って切り抜く9引数のdrawImageです。
function drawCover(context, source, x, y, width, height) {
  const scale = Math.max(width / source.width, height / source.height);
  const sourceWidth = width / scale, sourceHeight = height / scale;
  context.drawImage(source,
    (source.width - sourceWidth) / 2, (source.height - sourceHeight) / 2, sourceWidth, sourceHeight,
    x, y, width, height);
}

function drawGrid() {
  outputCanvas.width = 960; outputCanvas.height = 720;
  const context = outputCanvas.getContext('2d');
  context.fillStyle = '#ffffff'; context.fillRect(0, 0, 960, 720);
  const margin = 24, gap = 24;
  const width = (960 - margin * 2 - gap) / 2;
  const height = (720 - margin * 2 - gap) / 2;
  for (let row = 0; row < 2; row++) {
    for (let column = 0; column < 2; column++) {
      drawCover(context, inputCanvas,
        margin + column * (width + gap), margin + row * (height + gap), width, height);
    }
  }
  outputCanvas.setAttribute('aria-label', '入力写真を4枚並べた960×720 pxの画像');
  return { width, height, margin, gap, rows: 2, columns: 2 };
}

// コールバックで返るBlobをPromiseへ包み、awaitできるようにします。
function encodePng(canvas) {
  return new Promise((resolve, reject) => {
    try {
      canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('PNGを生成できませんでした')), 'image/png');
    } catch (error) { reject(error); }
  });
}

async function processImage() {
  if (busy) return;
  setBusy(true); releaseOutput();
  try {
    status.textContent = '1/3:選んだFileを読み込み、画像のデコードを待っています。';
    const file = await getInputFile();
    const dimensions = await readFileIntoCanvas(file);
    status.textContent = '2/3:読み込んだ画像をCanvasへ4枚配置しています。';
    const grid = drawGrid();
    status.textContent = '3/3:CanvasからPNGのBlobができるまで待っています。';
    const blob = await encodePng(outputCanvas);
    outputUrl = URL.createObjectURL(blob);
    download.href = outputUrl;
    const baseName = file.name.replace(/\.[^.]+$/, '') || 'image';
    download.download = `${baseName}_grid.png`;
    download.hidden = false;
    json.textContent = JSON.stringify({ input: { name: file.name, type: file.type, bytes: file.size, ...dimensions },
      output: { type: blob.type, bytes: blob.size, width: outputCanvas.width, height: outputCanvas.height,
        downloadName: download.download, grid },
      note: '名前・寸法・容量は今回の実入力と生成したBlobから取得しています。', userAgent: navigator.userAgent }, null, 2);
    status.textContent = '完了:画像の読み込み→4枚配置→PNG生成を順に待ちました。保存リンクを押せます。';
  } catch (error) {
    console.error(error); releaseOutput();
    inputCanvas.getContext('2d').clearRect(0, 0, inputCanvas.width, inputCanvas.height);
    outputCanvas.getContext('2d').clearRect(0, 0, outputCanvas.width, outputCanvas.height);
    json.textContent = '処理に失敗したため、保存できる結果はありません。';
    status.textContent = `処理できませんでした:${error.message}。HTTP起動、ファイル形式、画像を確認して再試行してください。`;
  } finally { setBusy(false); }
}

runButton.addEventListener('click', processImage);
picker.addEventListener('change', () => {
  releaseOutput(); json.textContent = '入力を変えました。まだ実行していません。';
  status.textContent = '選んだ画像で試すには実行ボタンを押してください。';
});
resetButton.addEventListener('click', () => {
  picker.value = ''; releaseOutput(); json.textContent = '内蔵の猫写真へ戻しました。まだ実行していません。';
  status.textContent = '内蔵の猫写真を選びました。実行ボタンを押してください。';
});
window.addEventListener('pagehide', () => { releaseOutput(); });

まとめ

配置の改造では、開始位置、写真の幅・高さ、間隔を一つずつ変えます。同時に複数を変えるより、どの数値が見た目に対応するかを確認しやすくなります。

drawImage()で画像の一部を選び、描画先の位置と寸法を指定できます。保存にはさらにtoBlob()などの書き出しが必要です。処理を段階に分けると、読み込み、座標、形式のどこで失敗したかを調べられます。

参考資料

制作時に確かめたこと

ツールを作る途中で実際に確認した症状と、変更を選んだ経緯です。日付は当時の記録日です。現在の実装や、このページの学習サンプルの検証とは分けて読んでください。

PCで動く加工が、スマホでは変わらなかった

スライダーの反応と、保存する画素を別々に見る

古写真風ツールでは、PCでセピアを調整すると変わるのに、スマホでは画像が変わらない状態を確認しました。同じ日に、電球色の明るさ・コントラスト、紙幣の色合い、古い手紙のインクの滲みでも、調整が効かないことを記録しています。

4月14日には、美肌ツールでもPCでは問題がないのにスマホでスライダーを動かしても画像が変化しないと報告し、別のぼかしサンプルの方式を使う改良を依頼しました。フィルター処理を疑ったのは当時の仮説であり、これらの記録だけで全ツール共通の原因や修正完了は確定できません。

この経験から検証を組み立てるなら、入力イベントが届いたか、Canvasの画素が変わったか、保存画像にも効果が入ったかを順に見ます。CSSでプレビューだけに効果を付けても、そのままCanvasへ書き出されるとは限りません。現在のCanvas 2Dのfilterにもブラウザの対応差があるため、使う端末で確認する必要があります。

仕様の確認:MDN:Canvas 2Dのfilterと対応状況(別タブ)