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?

ChatGPTで作ったLINEスタンプの画像を、ブラウザだけで自動分割・背景透過する(Canvas / TypeScript)

0
Last updated at Posted at 2026-10-22

はじめに

ChatGPTやGeminiにLINEスタンプ用のイラストを頼むと、8個のキャラクターが1枚の画像に4×2で並んで出てくることがあります。LINE Creators Marketに申請するには、これを1個ずつの透過PNGにして、決められたサイズとファイル名にそろえる必要があります。

画像編集ソフトで1個ずつ切り抜くと、8個でもそれなりに時間がかかります。そこで、ブラウザの中だけで「分割 → 背景透過 → LINEのサイズに配置 → PNGとZIPに書き出し」までを行う処理を書きました。画像はサーバーに送りません。

この記事では、その中身を4つに分けて説明します。

  1. 白いすき間から、分割する位置を見つける
  2. 端から続く白だけを透明にする(キャラクターの白い部分は残す)
  3. LINEの規格に合わせて配置する(スタンプは最大370×320で偶数)
  4. PNGに72dpiの情報を入れ、1MBに収める

LINE側の規格

LINE Creators Marketの制作ガイドラインで決められている、静止画の規格です(2026年10月21日に確認)。

種類 サイズ 個数
スタンプ 最大370×320px(縦横とも偶数) 8・16・24・32・40個
メイン画像 240×240px 1個
トークルームタブ画像 96×74px 1個
絵文字 180×180px 8〜40個

どれもPNGで背景は透過、1個1MB以下です。解像度は72dpi以上、カラーモードはRGBです。スタンプは画像の外枠と絵の間に10pxほどの余白が必要で、ZIPは60MB以下(絵文字は20MB以下)です。

以下のコードは、ImageData と同じ形の { data: Uint8ClampedArray, width, height } を受け取る純粋な関数として書いています。DOMに依存しないので、ブラウザでもNode.jsでも同じコードが動きます。

type Rgba = { data: Uint8ClampedArray; width: number; height: number };

1. 白いすき間から、分割する位置を見つける

AIに「コマとコマの間は白いすき間を広めに」と頼むと、絵と絵の間に、上から下まで(または左から右まで)真っ白な列・行ができます。これを数えれば、分割する位置がわかります。

ただし「白い列」を単純に数えると、キャラクターの白い体の中の列まで拾ってしまいます。そこで先に、画像の端からつながっている白だけを背景として塗りつぶし(次の章のマスク)、そのマスクで「端から端まで背景の列・行」を探します。

// 端から端まで背景の列・行
function emptyLines(mask: Uint8Array, width: number, height: number) {
  const emptyColumn = Array.from({ length: width }, (_, x) => {
    for (let y = 0; y < height; y++) if (!mask[y * width + x]) return false;
    return true;
  });
  const emptyRow = Array.from({ length: height }, (_, y) => {
    for (let x = 0; x < width; x++) if (!mask[y * width + x]) return false;
    return true;
  });
  return { emptyColumn, emptyRow };
}

背景でない列が続く区間を「絵」とみなし、隣り合う絵と絵のちょうど真ん中で分割します。幅の3%に満たない短い区間は、ゴミとして数えません。

function detectSplits(emptyLine: boolean[], minRun: number): number[] {
  const runs: { start: number; end: number }[] = [];
  let start = -1;
  for (let i = 0; i <= emptyLine.length; i++) {
    const content = i < emptyLine.length && !emptyLine[i];
    if (content && start < 0) start = i;
    if (!content && start >= 0) {
      if (i - start >= minRun) runs.push({ start, end: i - 1 });
      start = -1;
    }
  }
  const splits = [0];
  for (let k = 1; k < runs.length; k++)
    splits.push(Math.round((runs[k - 1].end + 1 + runs[k].start) / 2));
  splits.push(emptyLine.length);
  return splits;
}

2×2に並んだシマエナガのイラストと、検出した分割位置の赤線
図1:白いすき間の真ん中で、縦と横に1本ずつ分割位置が見つかりました(赤線)

すき間がまったくない画像(コマがくっついている、枠線で区切られている)は、ここで分割位置が見つかりません。その場合は「4列×2行」のような等分に切り替え、各境界の前後8%の範囲に背景の帯があれば、そこへ寄せます。少しずれた等分でも、絵の真ん中を切らずにすみます。

もう1つ、AIの画像には各コマに細い枠線が描かれていることがあります。枠線が残ると、次の背景透過が枠線で止まってしまうので、コマの端に「幅のほとんどが暗い行(列)」が数本あれば、その内側から切り出しています。

2. 端から続く白だけを透明にする

背景透過は、画像の4辺から塗りつぶし(flood fill)をかけ、端からつながっている、白に近いピクセルだけを背景とします。白い鳥の体や目のハイライトのように、輪郭線で囲まれた白は端とつながっていないので残ります。

「白に近い」は、RGBのうち一番暗いチャンネルが255からどれだけ離れているかで判定しています(初期値は24)。再帰ではなく配列のスタックを使うのは、大きな画像でコールスタックがあふれないようにするためです。

function whiteness(d: Uint8ClampedArray, i: number) {
  return Math.max(255 - d[i], 255 - d[i + 1], 255 - d[i + 2]);
}

function backgroundMask({ data, width, height }: Rgba, tolerance = 24) {
  const mask = new Uint8Array(width * height);
  const isBg = (p: number) =>
    data[p * 4 + 3] < 16 || whiteness(data, p * 4) <= tolerance;
  const stack: number[] = [];
  const seed = (p: number) => {
    if (!mask[p] && isBg(p)) {
      mask[p] = 1;
      stack.push(p);
    }
  };
  for (let x = 0; x < width; x++) {
    seed(x);
    seed((height - 1) * width + x);
  }
  for (let y = 0; y < height; y++) {
    seed(y * width);
    seed(y * width + width - 1);
  }
  while (stack.length) {
    const p = stack.pop()!;
    const x = p % width;
    if (x > 0) seed(p - 1);
    if (x < width - 1) seed(p + 1);
    if (p >= width) seed(p - width);
    if (p < width * (height - 1)) seed(p + width);
  }
  return mask;
}

マスクの部分のアルファを0にするだけだと、輪郭の外側に、アンチエイリアスの薄い白いフチが残ります。そこで、背景に接している1周ぶんのピクセルについて、白に近いほど透明にしています(暗い輪郭線のピクセルはそのまま残します)。

端からの塗りつぶしで背景と判定された部分をピンクで表示した画像
図2:背景と判定された部分(ピンク)。3羽は白い体が残っていますが、右下の1羽は体の下の輪郭線がとぎれていて、体の中まで背景になっています

この方式には、うまくいかない画像もあります。

  • 輪郭線が途切れていると、塗りつぶしが中に入り込み、白い体まで透けます
  • 背景に薄いグレーや影、グラデーションがあると、その部分が残ります

4羽を切り出して背景を透明にした結果。右下の1羽は体が透けている
図3:切り出して背景を透明にした結果(しきい値24)。右下は体まで透けてしまった失敗例です

どちらも、生成の段階で「背景は真っ白、影なし」「太くて濃い輪郭線」と頼んでおくと、かなり減ります。

3. LINEの規格に合わせて配置する

透明にした絵を、不透明なピクセルの範囲で切り詰めてから、LINEのキャンバスに配置します。

  • スタンプ: 最大370×320。周りに10pxの余白を取って縮小し、キャンバスは絵に合わせて小さくしてよい(ただし縦横とも偶数)
  • 絵文字・メイン画像・タブ画像: サイズ固定(180×180、240×240、96×74)。絵を中央に置く
type Spec = { maxWidth: number; maxHeight: number; margin: number; mode: 'fixed' | 'fit' };

function placement(cw: number, ch: number, s: Spec) {
  const scale = Math.min(
    (s.maxWidth - s.margin * 2) / cw,
    (s.maxHeight - s.margin * 2) / ch
  );
  const width = Math.max(1, Math.round(cw * scale));
  const height = Math.max(1, Math.round(ch * scale));
  const even = (n: number) => (n % 2 ? n + 1 : n);
  const canvasWidth =
    s.mode === 'fixed' ? s.maxWidth : Math.min(s.maxWidth, even(width + s.margin * 2));
  const canvasHeight =
    s.mode === 'fixed' ? s.maxHeight : Math.min(s.maxHeight, even(height + s.margin * 2));
  return {
    width, height,
    x: Math.floor((canvasWidth - width) / 2),
    y: Math.floor((canvasHeight - height) / 2),
    canvasWidth, canvasHeight,
  };
}

縮小は CanvasRenderingContext2D の drawImage で、imageSmoothingQuality = 'high' にしています。

4. PNGに72dpiを入れ、1MBに収める

canvas.toBlob(..., 'image/png') で作ったPNGには、解像度の情報(pHYs チャンク)が入りません。LINEのガイドラインは72dpi以上を求めているので、IHDR の直後に pHYs チャンクを差し込んでいます。72dpiは、pHYs の単位(1メートルあたりのピクセル数)で Math.round(72 / 0.0254) = 2835 です。チャンクのCRC32も計算して付けます。

1MBを超えたときは、upng-js で色数を256、128、64、32と減らして書き直し、収まった時点で止めます。それでも収まらない場合は、一番小さいものを返し、画面に「容量オーバー」と表示します。

最後に、スタンプは 01.png〜40.png、絵文字は 001.png〜040.png、メイン画像は main.png、タブ画像は tab.png という名前でZIPにまとめます。

まとめ

  • 分割位置は「端からつながる背景」のマスクで、端から端まで空いている列・行を探す
  • 背景透過は、端からの塗りつぶしにすると、キャラクターの中の白が残る
  • スタンプのキャンバスは、余白10pxと偶数サイズを守る
  • toBlob のPNGには72dpiが入らないので、pHYs を自分で足す

いちばん効くのは、実は生成のときの頼み方です。「白いすき間を広めに」「背景は真っ白、影なし」「輪郭線は太く」の3つを守った画像は、ほぼそのまま分割できます。

この処理は、stampchanの無料ツール「LINEスタンプ 背景透過・分割ツール」として公開しています。ブラウザの中だけで動き、画像はアップロードされません。

生成から申請までの手順は、「ChatGPTで作ったLINEスタンプを切り分けて透過する方法」にまとめています。

参考

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?