1. なぜ256pxのアイコンを16pxに縮小すると汚くなるのか?
Windowsアプリのアイコン(.ico)を作成する際、AI等で生成した256×256pxの高解像度PNGをそのまま 16×16 や 20×20、24×24 に縮小すると、以下の3つの劣化が起きます。
-
透明境界の黒ずみ:透過PNGの「透明ピクセル(Alpha=0)」には裏側で
RGB(0,0,0)が入っていることが多く、縮小時の補間で輪郭に黒が混ざってフチが濁る。 -
sRGB平均化による彩度低下:ガンマ補正されたsRGBのままピクセルを平均化すると、中間色が暗くくすむ。
-
多数決による細線の消失とハロー現象:面積平均で縮小すると1pxの細い枠線や隙間が背景に負けて消え、後からアンシャープマスクを強くかけると輪郭が白く浮く(オーバーシュート)。
今回はC#でこれらの問題を回避し、ドット絵職人が手直ししたようなクッキリした極小アイコンを生成する汎用的な実装アプローチを紹介します。
2. 対策①:縮小前のアルファ・ブリーディング(色滲み防止)
縮小処理にかける前に、不透明な輪郭ピクセルのRGB値を外側の完全透明領域へ数ピクセル分コピー(伝播)しておきます。アルファ値は透明なまま維持するため見た目は変わらず、縮小時の黒ずみを防げます。
// 汎用サンプル:透明ピクセル(Alpha == 0)に隣接する不透明ピクセルのRGBを伝播させる
public static void BleedTransparentPixels(byte[] rgbaBuffer, int width, int height, int passes = 4)
{
int total = width * height;
bool[] hasColor = new bool[total];
for (int i = 0; i < total; i++) hasColor[i] = rgbaBuffer[i * 4 + 3] > 16;
ReadOnlySpan<(int dx, int dy)> neighbors = [(-1, 0), (1, 0), (0, -1), (0, 1)];
bool[] nextHasColor = (bool[])hasColor.Clone();
for (int p = 0; p < passes; p++)
{
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int idx = y * width + x;
if (hasColor[idx]) continue;
int r = 0, g = 0, b = 0, count = 0;
foreach (var (dx, dy) in neighbors)
{
int nx = x + dx, ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height && hasColor[ny * width + nx])
{
int nOffset = (ny * width + nx) * 4;
r += rgbaBuffer[nOffset];
g += rgbaBuffer[nOffset + 1];
b += rgbaBuffer[nOffset + 2];
count++;
}
}
if (count > 0)
{
int offset = idx * 4;
rgbaBuffer[offset] = (byte)(r / count);
rgbaBuffer[offset + 1] = (byte)(g / count);
rgbaBuffer[offset + 2] = (byte)(b / count);
nextHasColor[idx] = true;
}
}
}
Array.Copy(nextHasColor, hasColor, total);
}
}
3. 対策②:Oklab知覚色空間でのリサイズ演算
sRGBのまま縮小計算を行うのではなく、人間の知覚に均一な Oklab色空間(パブリックドメイン) に変換してからリサイズとコントラスト調整を行います。
// リニアRGB(0.0〜1.0) から Oklab(L, a, b) への標準変換式
public static (float L, float a, float b) LinearRgbToOklab(float r, float g, float bl)
{
float l = MathF.Cbrt(0.41222147f * r + 0.53633253f * g + 0.05144599f * bl);
float m = MathF.Cbrt(0.21190349f * r + 0.68069954f * g + 0.10739695f * bl);
float s = MathF.Cbrt(0.08830246f * r + 0.28171883f * g + 0.62997870f * bl);
return (
0.21045425f * l + 0.79361778f * m - 0.00407204f * s,
1.97799849f * l - 2.42859220f * m + 0.45059370f * s,
0.02590403f * l + 0.78277176f * m - 0.80867576f * s
);
}
4. 対策③:局所クランプ付きシャープニングと段階的縮小
縮小後にエッジを強調する際、単純にラプラシアンフィルタ等をかけると、境界が元の色以上に白く飛ぶ「ハロー現象」が起きます。これを防ぐには、「縮小前の元エリア内に存在した最小輝度(minL)〜最大輝度(maxL)の範囲内でクランプする」のが効果的です。
// 周囲4近傍との輝度差からエッジを強調しつつ、元領域の[minL, maxL]でクランプして白浮きを防ぐ概念例
float deltaL = currentL - neighborAverageL;
float sharpenedL = Math.Clamp(currentL + deltaL * sharpStrength, sourceCellMinL, sourceCellMaxL);
また、16×16 や 20×20、24×24 といった極小サイズを作る際は、256pxから一気に落とすのではなく、一度3倍の中間サイズ(48px / 60px / 72px)へ縮小してから最終サイズへ統合する2段階構成にすると、細い線の構造が綺麗に残りやすくなります。
最後に Magick.NET の MagickImageCollection を使い、高DPI環境でもボケないようWindows標準の全11サイズ(16, 20, 24, 32, 40, 48, 64, 72, 96, 128, 256)をパッキングして .ico として書き出します。
まとめ:ドラッグ&ドロップで自動生成するツールにまとめました
個人開発のたびに毎回画像ソフトでサイズ別調整をする手間をなくすため、上記の知覚色空間パイプラインをさらに発展させて .NET 10 (Native AOT) で実装したアイコン作成ツール 「PinSharp ICO」 を開発しました。
ウィンドウに画像をドラッグ&ドロップするだけで、全11サイズをパッキングした高鮮明な .ico ファイルを自動で出力・保存します。ビルド済みバイナリや詳細はGitHubで公開していますので、自作アプリのアイコン作成にぜひ活用してみてください。
