0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

favicon を Canvas で生成するツールを作った — 16x16 を blurry にしない step downscaling アルゴリズム

0
Posted at

「favicon を作る」と言うと意外と工数がある: 16, 32, 48, 64, 180, 192, 512 と 7 サイズの PNG を用意して、HTML の <link>site.webmanifest も書く。500 行 vanilla JS で「テキスト・絵文字・画像のどれかから 7 サイズ全部生成、HTML スニペットも自動出力」のツールを書いた。実装中の 技術的な肝は downscaling だった。drawImage で 512→16 を 1 ステップでやると小さい favicon が どろどろにボケる。段階的に halve していくと文字や図形のエッジが立つ。

🌐 Demo: https://sen.ltd/portfolio/favicon-generator/
📦 GitHub: https://github.com/sen-ltd/favicon-generator

Screenshot

「一発 downscale」の悲しい結果

最初に書いたバージョンはこう:

function renderAt(size, content) {
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = size;
  const ctx = canvas.getContext("2d");
  // 文字を target size 上で直接描く
  ctx.font = `${size * 0.7}px sans-serif`;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(content, size / 2, size / 2);
  return canvas;
}

これで 16×16 を作ると 小さく描きすぎたフォントのレンダリングがぼやけて読めない 状態になる。ブラウザのフォントレンダラは「11px の sans-serif」を綺麗に出すための hinting 持ってない (Canvas API はサブピクセル レンダリング非対応)。

別案: 512×512 でレンダリングしてから 1 ステップで drawImage(src, 0, 0, 16, 16)。これも どろどろ。理由は次の通り。

なぜ単発 downscaling がぼやけるか

drawImage の resampling は (デフォルトでは) bilinear interpolation。dst の 1 ピクセルが src の何ピクセルに対応するかを計算して、近傍 4 ピクセルから重み付き平均を取る。

512 → 16 の場合、dst 1 ピクセルが src 32×32 = 1024 ピクセルに対応する。bilinear は近傍 4 つしか見ない。残り 1020 ピクセルの情報は 完全に捨てる。源画像の文字エッジが間引かれて、消えるか潰れるかする。

これは Canvas 仕様の問題ではなく信号処理の問題で、4 倍以上の縮小には box filter (= 全ピクセル平均) を段階的に重ねる のが基本。

step downscaling: halve しながら縮める

正解は 2 倍ずつ halve:

512 → 256 → 128 → 64 → 32 → 16

各ステップで drawImage(prev, 0, 0, half, half) を呼ぶと、bilinear が src 2×2 = 4 ピクセルを 1 つに平均化 する。これは box filter と等価で、信号情報がきれいに保たれる。最後の 16×16 で文字のエッジが立つ。

実装:

export function downscaleSteps(srcSize, dstSize) {
  if (srcSize <= 0 || dstSize <= 0) return [];
  if (srcSize <= dstSize * 2) return [dstSize];
  const steps = [];
  let cur = srcSize;
  while (cur > dstSize * 2) {
    cur = Math.max(dstSize, Math.round(cur / 2));
    steps.push(cur);
    if (cur <= dstSize) break;
  }
  if (steps[steps.length - 1] !== dstSize) steps.push(dstSize);
  return steps;
}

ロジック:

  • src ≤ 2x dst なら 1 ステップで OK (bilinear で十分な精度)
  • それ以外は 「次の dst × 2 を下回らない範囲」 で halve を重ねる
  • 最後は dst にぴったり合わせる

例:

  • 512 → 16: [256, 128, 64, 32, 16] (5 step)
  • 192 → 16: [96, 48, 24, 16] (4 step)
  • 64 → 32: [32] (1 step、src ≤ 2x dst なので)

レンダリング側はこの step list を順に通す:

function downscale(srcCanvas, target) {
  const steps = downscaleSteps(srcCanvas.width, target);
  let current = srcCanvas;
  for (const stepSize of steps) {
    const next = document.createElement("canvas");
    next.width = next.height = stepSize;
    const ctx = next.getContext("2d");
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = "high";
    ctx.drawImage(current, 0, 0, stepSize, stepSize);
    current = next;
  }
  return current;
}

imageSmoothingQuality: "high" で bilinear をフルにかける。Chromium 系では trilinear / bicubic に近い品質になる。

テストで step list を保証する

Node でテストできる pure 関数にしておいた:

test("each step is ≤ 2x the next", () => {
  const steps = downscaleSteps(512, 16);
  for (let i = 1; i < steps.length; i++) {
    assert.ok(steps[i - 1] / steps[i] <= 2.5, `step ratio too aggressive: ${steps.join(",")}`);
  }
});

test("final step is exactly dst", () => {
  assert.equal(downscaleSteps(192, 16).pop(), 16);
});

test("when src ≤ 2x dst: single resize", () => {
  assert.deepEqual(downscaleSteps(64, 32), [32]);
});

「ratio ≤ 2.5」は (2.0 厳密にすると 192 → 96 → 48 → 24 → 1624/16=1.5 で OK だが 192/96=2.0 がぎりぎりなので) 数値誤差マージン。assert.ok(ratio <= 2.5) で実用十分。

マスター解像度を 512 に固定する

ユーザがテキストを入力したり絵文字を選んだとき、毎回 512×512 でマスター画像を描く。その後 step downscale を 7 種類の target size に適用する:

const HI_RES = 512;

function renderAtMaster(config) {
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = HI_RES;
  // ... 背景、文字、絵文字、画像のいずれかを描画
  return canvas;
}

export function renderAllSizes(config) {
  const master = renderAtMaster(config);
  const out = new Map();
  for (const s of [16, 32, 48, 64, 180, 192, 512]) {
    out.set(s, downscale(master, s));
  }
  return out;
}

メリット:

  • テキストは 512px サイズで描画する (= フォントレンダラがフルに hinting を効かせられる)
  • 各 target size には 必ず縮小 で到達 (拡大は発生しない)
  • 形状マスク (rounded / circle) は 最終サイズで適用 (シャープなエッジ)

絵文字レンダリング

Canvas で絵文字を描くときは OS の絵文字フォントを font-family で指定 する:

ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.fillText("🚀", canvas.width/2, canvas.height/2);

macOS では Apple Color Emoji、Windows では Segoe UI Emoji、Linux/Android では Noto Color Emoji。color emoji はベクター → ラスター変換した PNG を埋め込んだフォントなので、Canvas で描いてもフルカラーで出る。

形状マスク

function applyShape(canvas, shape) {
  if (!shape || shape === "square") return canvas;
  const size = canvas.width;
  const masked = document.createElement("canvas");
  masked.width = masked.height = size;
  const ctx = masked.getContext("2d");
  ctx.save();
  ctx.beginPath();
  if (shape === "circle") {
    ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
  } else if (shape === "rounded") {
    const r = size * 0.22;
    roundRect(ctx, 0, 0, size, size, r);
  }
  ctx.clip();
  ctx.drawImage(canvas, 0, 0);
  ctx.restore();
  return masked;
}

clip() で path 内側だけ描画許可、drawImage で元 canvas を流し込む。downscale 後 に適用するのが大事 — 縮小と同時にマスクすると エッジが ぼやける。

PNG ダウンロード

Canvas → Blob → 一時 URL → 自動クリック の標準パターン:

export function downloadCanvasAsPng(canvas, filename) {
  canvas.toBlob((blob) => {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      document.body.removeChild(a);
      URL.revokeObjectURL(a.href);
    }, 100);
  }, "image/png");
}

ZIP で全サイズまとめ download も考えたが JSZip を bundle すると 100KB 級。個別ボタン にしたほうがシンプルで、必要なサイズだけ取れる利点もある。

アーキテクチャ

core.js     ← SIZES, downscaleSteps, validateConfig, snippets (DOM 非依存、21 tests)
render.js   ← Canvas で master 描画 → step downscale → 形状マスク
presets.js  ← 6 種のプリセット
app.js      ← UI グルー (input → validate → render → preview + download)

core.js には DOM が登場しない。Node テストで step list の正しさを完全保証してから、render.js が Canvas 操作を担当する。

まとめ

  • favicon は 7 種類のサイズ + HTML + manifest が標準セット
  • 単発の big-to-tiny downscale は bilinear filter の限界で必ずぼやける
  • 正解は 2x ずつ halve を繰り返す step downscaling (4 ピクセル → 1 ピクセルの box filter に近づく)
  • step list の計算は pure 関数として Node テストで保証できる ((src) / 2 / 2 / ... → dst)
  • マスター画像は 512×512 固定、target には常に縮小、形状マスクは最終サイズで
  • ZIP ダウンロードはあえてやらず、個別ボタン で必要なサイズだけ取れる

リポジトリ: https://github.com/sen-ltd/favicon-generator

このツールは弊社の OSS ポートフォリオ #255 として作成しました。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?