はじめに
ChatGPTやGeminiにLINEスタンプ用のイラストを頼むと、8個のキャラクターが1枚の画像に4×2で並んで出てくることがあります。LINE Creators Marketに申請するには、これを1個ずつの透過PNGにして、決められたサイズとファイル名にそろえる必要があります。
画像編集ソフトで1個ずつ切り抜くと、8個でもそれなりに時間がかかります。そこで、ブラウザの中だけで「分割 → 背景透過 → LINEのサイズに配置 → PNGとZIPに書き出し」までを行う処理を書きました。画像はサーバーに送りません。
この記事では、その中身を4つに分けて説明します。
- 白いすき間から、分割する位置を見つける
- 端から続く白だけを透明にする(キャラクターの白い部分は残す)
- LINEの規格に合わせて配置する(スタンプは最大370×320で偶数)
- 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;
}

図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羽は体の下の輪郭線がとぎれていて、体の中まで背景になっています
この方式には、うまくいかない画像もあります。
- 輪郭線が途切れていると、塗りつぶしが中に入り込み、白い体まで透けます
- 背景に薄いグレーや影、グラデーションがあると、その部分が残ります

図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スタンプを切り分けて透過する方法」にまとめています。