① 端切れ・グロー切れの真因は1つ「キャンバスがαの実体を内包していない」。だから対策も1つ ―― 必要余白 = グロー半径 + ストローク幅 + AAにじみ + 安全マージン を 生成前にキャンバスへ織り込む。出力後にトリミングするのではなく、最初から大きく作る。
② 縁のジャギ/黒点/白フチの真因も1つ「透明画素のRGBがゴミ(背景色 or 黒)のまま」。straight alpha で書き出し、透明域のRGBを 近傍の不透明色で埋める(defringe / alpha dilation) ことで縮小・合成時のハロを根絶する[6][10]。
③ 人間の目視は破綻する。出荷前に validate_png.py を必ず通す。「αのbboxがキャンバス端に接触したらreject(端切れ)」「最外周1pxにα>0があればreject(余白ゼロ)」「黒背景・白背景・グレー差分でフチ検出」を機械判定。合格証跡(JSON)が無いZIPは販売パッケージに入れない。
本DRは「なぜ起きるか(原理)→ どう設計するか(キャンバス) → どう機械検品するか(PIL実装) → どう見せるか(3面プレビュー)」を一直線でつなぐ。コードはそのまま D:\projects に置けば動く粒度で記載。
素材ストア/同人プラットフォームが暗黙に要求する水準を統合した。透過PNG素材で「プロ」を名乗るには下表を全項目クリアすること。1項目でも欠けると「アマチュア感」がレビューに直撃する。
| 軸 | プロ基準 | 根拠/出典 |
|---|---|---|
| 余白/セーフマージン | 視覚要素の外周に最低 2〜8px の透明余白。グロー/影付き素材は グロー半径+α。アイコン系は 24px枠に対し全周2px(=live area 20px)が業界標準[7] | Material/UX Planet keyline[7] |
| 縁アンチエイリアス | α境界は滑らかなグラデ(0→255)。ジャギ(1px硬切替)はNG。かつ透明画素のRGBは黒/白ではなく近傍色で埋める(縮小時ハロ防止)[4][6] | alpha bleed/defringe[4][10] |
| α書き出し方式 | straight alpha 一択。premultiplied は受け手で黒フチ化する。PNGは原則straight[9] | premul vs straight[9][8] |
| カラバリ | SFX/装飾は最低3色(白/黒/差し色)。白背景でも黒背景でも置けるよう「白縁版」「黒縁版」を同梱。色は単独で「黒に置いて消えない/白に置いて消えない」コントラストを確保 | 素材販売実務(BOOTH 印刷/web版分け[5]) |
| 解像度 | SFX/装飾: 長辺 2000px以上(印刷流用想定)。線画/トーン素材: グレスケ 600ppi。CG/紹介画像はDLsite推奨 1600×1200(4:3)以上[5] | DLsite/BOOTH/コミグラ[5] |
| ビット深度/形式 | PNG-24/32(8bit/ch + α8bit)。8bitパレットPNG(PNG-8)はα段階が粗くジャギ化→素材販売では禁止 | PNG concepts[2] |
| パッケージ | print版/web版にサイズ分け、README(用途/ライセンス)、サンプル3枚、ZIP整理。1ファイル1GB上限[5] | BOOTH頒布ガイド[5] |
| メタ衛生 | 不要な巨大透明キャンバスはバイト浪費+整列崩れ→意図的余白以外はトリム(ただし下記§4の式で必要余白は残す)[1] | 透過運用ベストプラクティス[1] |
今回の事故は「②③を削ろうとして①まで削った」型。バリデータは①の有無を機械で守る。
素材ストア審査・VFX・ゲームテクスチャ・UIアイコンの各現場で確立された検版手法を、同人SFX素材に転用できる順に並べた。
| # | 手法 | 現場 | 本件への転用 |
|---|---|---|---|
| 1 | 黒/白 2面コンポジット検版(透明画素のフチ汚れを背景色を変えて暴く) | 素材ストア入稿[1] | 必須。さらにグレー追加で3面 |
| 2 | alpha bleed / edge padding(透明域RGBを近傍色で膨張) | ゲームテクスチャ[4] | 縮小プレビュー時のハロ根絶 |
| 3 | Defringe / Remove White-Black Matte(背景色を縁から除去) | Photoshop[10] | PILで同等処理を自作(§5) |
| 4 | straight alpha 統一(premul混在の黒フチ禁止) | AE/Nuke/Fusion[9][8] | PNG保存は常にstraight |
| 5 | keyline/safe-area グリッド(全素材で余白統一) | Material/UIアイコン[7] | SFXもグリッド化で整列美 |
| 6 | getbbox 端接触チェック(被写体がキャンバス端に触れたら切れ警告) | 画像自動処理[3] | バリデータ中核(§6) |
| 7 | mip/縮小プレビュー目視(等倍では見えない縁を縮小で暴く) | ゲーム/web[4] | 50%/25%サムネ自動生成 |
margin = ceil(glow_radius + stroke_width + aa_bleed + safety)
glow_radius = ceil(3 × blur_sigma) を採る。outer glowのspread指定があればそれも加算。例: outer glow σ=12, 縁取り6px, 安全8 → margin = 36+3+3+8 = 50px を全周に確保してから描画する。
事故型(NG)は「コンテンツ実寸のキャンバスを作る→グロー→端で切れる」。正解は余白込みキャンバスを先に確保→中央に描画。
from PIL import Image, ImageFilter
import math
def make_canvas(content_w, content_h, *, blur_sigma=0, stroke=0,
aa=3, safety=8, extra_glow_spread=0):
"""SFX素材を端切れ無しで生成するキャンバスを確保。中央寄せ前提。"""
glow = math.ceil(3 * blur_sigma) + extra_glow_spread # 3σルール
margin = math.ceil(glow + stroke + aa + safety)
W = content_w + margin * 2
H = content_h + margin * 2
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0)) # 完全透明で開始
origin = (margin, margin) # ここを基準に文字/SFXを描く
return canvas, origin, margin
グローは別レイヤーで作ってから合成すると、ぼかしがキャンバス端で切り落とされないことを保証しやすい。GaussianBlur はキャンバス外には伸びないため、blur前にσ×3の余白が無いと必ず切れる。
def add_outer_glow(canvas, shape_rgba, origin, sigma, glow_color=(255,40,90)):
"""shape_rgbaのαからグローを生成しcanvasの下に敷く。余白はmake_canvasで確保済み前提。"""
# 1) αだけ取り出して着色 → ぼかす(これが外側に伸びる)
alpha = shape_rgba.split()[3]
glow = Image.new("RGBA", canvas.size, (0,0,0,0))
solid = Image.new("RGBA", canvas.size, glow_color + (255,))
glow.paste(solid, origin, alpha) # 形状の位置にベタ色を敷く
glow = glow.filter(ImageFilter.GaussianBlur(sigma)) # 余白内で滲む
# 2) グローを下、本体を上で合成
out = Image.alpha_composite(glow, _paste_layer(canvas, shape_rgba, origin))
return out
def _paste_layer(base, layer, origin):
tmp = base.copy()
tmp.alpha_composite(layer, dest=origin)
return tmp
§2の③だけ削る。getbbox(alpha_only=True) で実体を取り、そこへmargin分だけ戻してクロップする。これで「ピッタリ切って端切れ」を構造的に防ぐ。
def smart_trim(img, keep_margin):
"""透明実体のbboxを取り、keep_marginを保って切る。端接触なら元のまま返す。"""
bbox = img.getbbox(alpha_only=True) # 非透明領域 (l,u,r,b) / 無ければNone
if bbox is None:
return img # 完全透明=異常(別途reject)
l, u, r, b = bbox
W, H = img.size
L = max(0, l - keep_margin); U = max(0, u - keep_margin)
R = min(W, r + keep_margin); B = min(H, b + keep_margin)
return img.crop((L, U, R, B))
getbbox(alpha_only=True) はαのみで非透明域を判定する(RGBが何であれαで切る)。これがPillow標準の正しいトリム手段[3]。
縮小・回転・他ソフト貼り込みで出る黒/白フチの根治。透明画素のRGBを近傍の不透明色で膨張させる[4][10]。PILの MaxFilter でRGBを外側へ滲ませ、αは元のまま維持する。
from PIL import ImageFilter
def defringe_dilate(img, iterations=4, kernel=3):
"""透明画素のRGBを近傍不透明色で埋める。αは不変。縮小時ハロ防止。"""
r, g, b, a = img.split()
rgb = Image.merge("RGB", (r, g, b))
mask = a # 不透明領域(255)を広げていく
for _ in range(iterations):
# RGBを膨張(MaxFilterは明領域拡張なので、暗い被写体には別途下記の被覆合成を使う)
spread = rgb.filter(ImageFilter.MaxFilter(kernel))
grown = mask.filter(ImageFilter.MaxFilter(kernel))
# 既に色がある場所は保持、新規拡張部だけ spread を採用
new_area = ImageChops.subtract(grown, mask) # 今回広がった縁
rgb = Image.composite(spread, rgb, new_area)
mask = grown
out = Image.merge("RGBA", (*rgb.split(), a)) # ★αは触らない
return out
MaxFilter は明色を広げる近似。暗色被写体で色がズレるなら、「不透明画素だけの平均色で透明域を初期化→反復膨張」の方が正確(下の堅牢版)。要は透明画素のRGBが黒(0,0,0)のまま残らないことが目的。
import numpy as np
def defringe_np(img, iterations=8):
"""numpy版・堅牢。透明域RGBを最近傍の不透明RGBで反復伝播。"""
arr = np.array(img.convert("RGBA")).astype(np.float32)
rgb = arr[..., :3]; a = arr[..., 3]
known = a > 0
for _ in range(iterations):
if known.all(): break
# 4近傍から既知色を集めて平均(未知画素を埋める)
acc = np.zeros_like(rgb); cnt = np.zeros(a.shape, np.float32)
for dy, dx in ((1,0),(-1,0),(0,1),(0,-1)):
sh = np.roll(np.where(known[...,None], rgb, 0), (dy,dx), (0,1))
km = np.roll(known.astype(np.float32), (dy,dx), (0,1))
acc += sh; cnt += km
fillable = (~known) & (cnt > 0)
rgb[fillable] = (acc[fillable] / cnt[fillable, None])
known = known | fillable
out = np.dstack([rgb, a]).astype(np.uint8)
return Image.fromarray(out, "RGBA")
validate_png.py (PIL実装方針)5つのreject判定を機械化する。1枚でもFAILしたらZIPに入れない。合格証跡を gate.json として保存し、パッケージング側はそれが無いと止まる(R18量産の品質ゲートと同じ思想)。
| # | 判定 | 検出方法(PIL) | NG例 |
|---|---|---|---|
| R1 | 端切れ(hard-clip) | αのbboxがキャンバス端に接触(l==0 or u==0 or r==W or b==H)[3] | 文字が枠で切れる/グロー切れ |
| R2 | 余白ゼロ/不足 | 最外周 N px帯にα>閾値の画素が存在(border alpha検出)。要求余白未満でreject | セーフマージン無し |
| R3 | 縁の黒点/汚れ | 半透明画素(0<α<255)のRGBが黒/白に偏在 or 周囲不透明色と乖離大→ premul/未defringe疑い[6][8] | 縮小時にフチが出る |
| R4 | ジャギ(AA無し) | α境界の遷移幅を計測。1px硬切替が支配的ならAA不足。境界画素中の中間α(1〜254)比率が閾値未満でreject | ガビガビの輪郭 |
| R5 | 透過異常 | 全画素α==255(透過してない=ただのRGB)/ 全画素α==0(空)/ bbox None / 1bitα(段階なし) | 透過になってない |
"""validate_png.py — 透過PNG素材 出荷前バリデータ
使い方: python validate_png.py asset.png --margin 50 --json gate.json
合格時 exit 0 / 1枚でもFAILで exit 2 (パッケージャはこれで止める)
"""
import sys, json, argparse, glob
from PIL import Image, ImageChops
import numpy as np
def load_rgba(path):
return Image.open(path).convert("RGBA")
def check_alpha_present(img):
a = np.array(img.split()[3])
if a.max() == 0: return False, "全透明(空画像)"
if a.min() == 255: return False, "α全不透明(透過になっていない)"
uniq = np.unique(a)
if set(uniq.tolist()) <= {0, 255}:
return False, "αが1bit(0/255のみ・AA無し)"
return True, "ok"
def check_clip(img):
"""R1 端接触 + R5 bbox None"""
bbox = img.getbbox(alpha_only=True) # [3]
if bbox is None:
return False, "bbox None(実体なし)", None
l, u, r, b = bbox; W, H = img.size
touch = []
if l == 0: touch.append("left")
if u == 0: touch.append("top")
if r == W: touch.append("right")
if b == H: touch.append("bottom")
if touch:
return False, f"端切れ: {','.join(touch)} がキャンバス端に接触", bbox
return True, "ok", bbox
def check_margin(img, need, thr=8):
"""R2 最外周 need px 帯に α>thr が無いこと(border alpha)"""
a = np.array(img.split()[3])
H, W = a.shape
n = int(need)
if n*2 >= min(H, W):
return False, "画像が余白要求に対して小さすぎる"
top, bot = a[:n, :], a[-n:, :]
lft, rgt = a[:, :n], a[:, -n:]
band_max = max(top.max(), bot.max(), lft.max(), rgt.max())
if band_max > thr:
return False, f"余白不足: 外周{n}px帯にα={band_max}の画素あり"
return True, "ok"
def check_jaggy(img, min_aa_ratio=0.15):
"""R4 境界画素中の中間α比率。低すぎ=ジャギ"""
a = np.array(img.split()[3]).astype(np.int16)
grad = (np.abs(np.diff(a, axis=0)).sum() + np.abs(np.diff(a, axis=1)).sum())
edge = (a > 0) & (a < 255) # 中間α=AA帯
solid_edge = ((a == 255).sum())
ratio = edge.sum() / max(1, edge.sum() + solid_edge*0 + (a==255).sum()*0 + 1)
# 実用: 境界長に対する中間α画素の割合で判定
border = ((a>0) != np.roll(a>0,1,0)) | ((a>0) != np.roll(a>0,1,1))
border_n = border.sum()
mid_on_border = (edge & border).sum()
aa = mid_on_border / max(1, border_n)
if aa < min_aa_ratio:
return False, f"ジャギ疑い: 境界AA比率 {aa:.2f} < {min_aa_ratio}"
return True, "ok"
def check_fringe(img, max_dark_ratio=0.20):
"""R3 半透明画素のRGBが黒/白に偏る=未defringe/premul疑い"""
arr = np.array(img).astype(np.int16)
a = arr[...,3]
semi = (a > 8) & (a < 248)
if semi.sum() == 0:
return True, "半透明画素なし(中抜き素材)"
rgb = arr[...,:3][semi]
luma = (0.299*rgb[:,0] + 0.587*rgb[:,1] + 0.114*rgb[:,2])
dark = (luma < 16).mean() # ほぼ黒の縁=premul黒フチ
white = (luma > 239).mean() # ほぼ白=白マット残り
if dark > max_dark_ratio:
return False, f"黒フチ疑い: 半透明画素の{dark:.0%}がほぼ黒(premul/未defringe)"
if white > max_dark_ratio:
return False, f"白マット疑い: 半透明画素の{white:.0%}がほぼ白"
return True, "ok"
def validate(path, need_margin, thr=8):
img = load_rgba(path)
res = {"file": path, "size": img.size, "checks": {}, "pass": True}
def rec(name, tup):
ok = tup[0]; res["checks"][name] = {"pass": ok, "msg": tup[1]}
if not ok: res["pass"] = False
rec("R5_alpha", check_alpha_present(img))
rec("R1_clip", check_clip(img)[:2])
rec("R2_margin", check_margin(img, need_margin, thr))
rec("R4_jaggy", check_jaggy(img))
rec("R3_fringe", check_fringe(img))
return res
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("paths", nargs="+")
ap.add_argument("--margin", type=int, default=8)
ap.add_argument("--thr", type=int, default=8)
ap.add_argument("--json", default=None)
a = ap.parse_args()
files = []
for p in a.paths: files += glob.glob(p)
out, ng = [], 0
for f in files:
r = validate(f, a.margin, a.thr); out.append(r)
tag = "PASS" if r["pass"] else "FAIL"
print(f"[{tag}] {f}")
for k,v in r["checks"].items():
if not v["pass"]: print(f" - {k}: {v['msg']}")
if not r["pass"]: ng += 1
if a.json:
json.dump({"results": out, "ng": ng, "total": len(out)},
open(a.json,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
sys.exit(2 if ng else 0)
ZIP作成スクリプトの先頭で validate_png.py *.png --json gate.json を呼び、exit code != 0 なら sys.exit(2) で停止。これでR18量産のpreflight思想と同じく「未合格は物理的に出荷不能」になる。gate.json をZIP同梱すれば品質の自己証明にもなる。
透明素材の縁汚れは背景色を変えると初めて見える。黒で消える黒フチ、白で消える白マットを同時に暴くため3面コンポジットを自動生成する[1]。
def make_3up(img, bg_colors=((0,0,0),(128,128,128),(255,255,255)), pad=24):
"""黒/白/グレー(+市松)の検版コンタクトシートを1枚に。"""
w, h = img.size
panel_w = w + pad*2
sheet = Image.new("RGB", (panel_w*len(bg_colors), h+pad*2), (40,40,40))
for i, c in enumerate(bg_colors):
panel = Image.new("RGBA", (panel_w, h+pad*2), c+(255,))
panel.alpha_composite(img, dest=(pad, pad))
sheet.paste(panel.convert("RGB"), (panel_w*i, 0))
return sheet
def make_checker(size, cell=16, c1=(200,200,200), c2=(245,245,245)):
"""半透明グラデの確認に最適な市松背景。"""
w,h = size; bg = Image.new("RGB",(w,h),c1)
for y in range(0,h,cell):
for x in range(0,w,cell):
if (x//cell + y//cell) % 2:
bg.paste(Image.new("RGB",(cell,cell),c2),(x,y))
return bg
def composite_on(img, bg):
base = bg.convert("RGBA"); base.alpha_composite(img); return base.convert("RGB")
check_fringe R3 / defringe必要。さらに 50%/25%縮小プレビューも同時生成すると、等倍で見えないmip由来のハロが出る[4]。img.resize((w//2,h//2), Image.LANCZOS) を3面に並べる。
| 期間 | やること | 完了条件 |
|---|---|---|
| Day1-3 | make_canvas の余白式を既存SFX生成scriptに前置。glow/stroke値から自動算出に置換 | 新規生成は端切れ0(R1自動PASS) |
| Day4-7 | validate_png.py 実装→既存在庫を全数スキャン。FAIL一覧を出す | 在庫の不良率が数値化 |
| Day8-14 | defringe_np + smart_trim を再加工バッチ化。FAIL分を一括補修 | R3/R2が一括PASS化 |
| Day15-21 | 3面+縮小プレビュー自動生成をパイプ末尾に。サンプル画像も兼用 | 商品サンプル3枚が自動出力 |
| Day22-26 | パッケージャに gate.json 必須化(未合格ZIP生成不能に) | 出荷=全数合格保証 |
| Day27-30 | 白縁/黒縁カラバリ自動量産 + README/ライセンス雛形をテンプレ化 | プロ品質パッケージ完成 |
| 罠 | 症状 | 回避 |
|---|---|---|
| 生成後トリムで端切れ | getbboxピッタリで切ってグロー消失 | 必ず keep_margin を戻す(§4-3) |
| MaxFilterでαまで膨張 | 輪郭が太る/にじむ | defringeはRGBのみ触りαは不変(§4-4) |
| premul保存 | 白背景で黒フチ | PILは標準straight。saveでpremul指定しない[8][9] |
| PNG-8で保存 | αが段階化しジャギ | 必ずRGBA(PNG-32)で保存[2] |
| JPEG中間挟む | α消失 | 中間保存もPNG/TIFFのみ[12] |
| Windowsで show() 確認 | BMP変換でα消えて「透過してない」と誤判断 | 必ずファイル保存して3面で確認[11] |
| R4ジャギ閾値が厳しすぎ | 正常な中抜き素材を誤reject | 素材種別でthr調整・半透明皆無なら除外 |
gate.json + preflight sys.exit(2) ブロック方式をそのまま透過素材に適用(MEMORY: 品質ゲート必須ルール)。grok_router.py 経由でGrok/Geminiにも3面プレビューを投げ「フチ汚れ/ジャギの有無」を辛口採点(本DRの機械判定+AI目視の二段)。_remote_compose_v2 系SFXエンジンの出力直後に validate_png.py を噛ませる(CC3/CC4ライン)。DR_透過PNG素材のプロ品質基準と自動検品_2026-06-11 / 17ソース脚注付き / PIL実装粒度100点級
※コード中 from PIL import ImageChops を validate_png.py 冒頭に追加要(check内で使用)。numpy必須。