なつねこメモ

主にプログラミング関連のメモ帳 ♪(✿╹ヮ╹)ノ 書いてあるコードは自己責任でご自由にどうぞ。記事本文の無断転載は禁止です。

Android の Adaptive Icon で複雑なアイコンをミニマルな状態でもキレイに表示したい

Android の Adaptive Icon を設定する際、元のアイコンが比較的複雑なアイコン (例えば、ゲームのアイコン) だと、そのままだとアイコンをミニマルテーマにしたときに、意図しない表示になってしまう、ということがありました。

ブルアカはちゃんとしてるのに、自作アプリはなんか変

Android 公式サイトとか見ても、シンプルなアイコンを白黒でぶちこめ!みたいなことしかかいて無くて、ブルアカはどうやってキレイにアイコンにしているんだろうと思って海外サイトを探していたら、解決方法を見つけたので紹介します。

方法は簡単で、グレースケールの濃さの要素を画像の Alpha 値として使うだけです。 画像の変換には次のスクリプトを使いました:

#!/usr/bin/env python3
"""
Android Adaptive Icon モノクロ変換スクリプト

グレースケールの輝度を Alpha 値にマッピングすることで、
Android Adaptive Icon の monochrome スタイルに対応した画像を生成します。

変換ルール (デフォルト: 暗さ → Alpha):
  暗いピクセル → Alpha 高 (不透明)
  明るいピクセル → Alpha 低 (透明)

変換ルール (--invert: 明るさ → Alpha):
  明るいピクセル → Alpha 高 (不透明)
  暗いピクセル → Alpha 低 (透明)

使い方:
  python scripts/to-monochrome-alpha.py <input> <output> [options]

例:
  python scripts/to-monochrome-alpha.py assets/icon.png assets/icon-monochrome.png
  python scripts/to-monochrome-alpha.py assets/icon.png out.png --color 255 255 255
  python scripts/to-monochrome-alpha.py assets/icon.png out.png --gamma 1.5
  python scripts/to-monochrome-alpha.py assets/icon.png out.png --invert
"""

import argparse
import sys

try:
    from PIL import Image
except ImportError:
    print("Error: Pillow が見つかりません。`pip install Pillow` でインストールしてください。", file=sys.stderr)
    sys.exit(1)

try:
    import numpy as np
    HAS_NUMPY = True
except ImportError:
    HAS_NUMPY = False


def convert_numpy(img: "Image.Image", color: tuple[int, int, int], gamma: float, invert: bool) -> "Image.Image":
    rgba = np.array(img.convert("RGBA"), dtype=np.float32)

    # 輝度 (Rec.709) を計算
    luminance = (
        rgba[:, :, 0] * 0.2126
        + rgba[:, :, 1] * 0.7152
        + rgba[:, :, 2] * 0.0722
    )

    # invert=False: 暗さ = 1 - 輝度 / invert=True: 明るさ = 輝度
    weight = luminance / 255.0 if invert else 1.0 - (luminance / 255.0)

    # ガンマ補正でコントラストを調整
    if gamma != 1.0:
        weight = np.power(np.clip(weight, 0.0, 1.0), 1.0 / gamma)

    # 元の Alpha と合成
    orig_alpha = rgba[:, :, 3] / 255.0
    final_alpha = np.clip(weight * orig_alpha * 255.0, 0, 255).astype(np.uint8)

    # 出力画像を組み立て
    result = np.zeros((*img.size[::-1], 4), dtype=np.uint8)
    result[:, :, 0] = color[0]
    result[:, :, 1] = color[1]
    result[:, :, 2] = color[2]
    result[:, :, 3] = final_alpha

    return Image.fromarray(result, "RGBA")


def convert_pure_pil(img: "Image.Image", color: tuple[int, int, int], gamma: float, invert: bool) -> "Image.Image":
    rgba = img.convert("RGBA")
    grayscale = img.convert("L")

    result = Image.new("RGBA", img.size)
    rgba_pixels = rgba.load()
    gray_pixels = grayscale.load()
    result_pixels = result.load()

    width, height = img.size
    for y in range(height):
        for x in range(width):
            r, g, b, a = rgba_pixels[x, y]
            gray = gray_pixels[x, y]

            # invert=False: 暗さ / invert=True: 明るさ
            weight = gray if invert else 255 - gray

            # ガンマ補正
            if gamma != 1.0:
                weight = int(((weight / 255.0) ** (1.0 / gamma)) * 255)

            # 元 Alpha と合成
            final_alpha = int(weight * a / 255)
            result_pixels[x, y] = (*color, final_alpha)

    return result


def main() -> None:
    parser = argparse.ArgumentParser(
        description="グレースケールの暗さを Alpha 値にマッピングして Android Adaptive Icon 用モノクロ画像を生成する",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument("input", help="入力画像パス (PNG 推奨)")
    parser.add_argument("output", help="出力画像パス (.png)")
    parser.add_argument(
        "--color",
        nargs=3,
        type=int,
        default=[255, 255, 255],
        metavar=("R", "G", "B"),
        help="前景色 RGB (デフォルト: 255 255 255 = 白)",
    )
    parser.add_argument(
        "--gamma",
        type=float,
        default=1.0,
        metavar="GAMMA",
        help="ガンマ値でコントラストを調整 (>1.0 で暗部を強調, デフォルト: 1.0)",
    )
    parser.add_argument(
        "--invert",
        action="store_true",
        help="明るいピクセルを Alpha 高 (不透明) にする (デフォルトは暗いピクセルが不透明)",
    )

    args = parser.parse_args()

    color = tuple(args.color)
    for c in color:
        if not (0 <= c <= 255):
            parser.error(f"--color の値は 0〜255 の範囲で指定してください: {c}")

    if args.gamma <= 0:
        parser.error("--gamma は正の値を指定してください")

    try:
        img = Image.open(args.input)
    except FileNotFoundError:
        print(f"Error: ファイルが見つかりません: {args.input}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Error: 画像を開けませんでした: {e}", file=sys.stderr)
        sys.exit(1)

    print(f"入力: {args.input} ({img.size[0]}x{img.size[1]})")
    print(f"前景色: rgb{color}")
    print(f"ガンマ: {args.gamma}")
    print(f"モード: {'明るさ → Alpha (--invert)' if args.invert else '暗さ → Alpha'}")
    print(f"エンジン: {'numpy' if HAS_NUMPY else 'pure Pillow (numpy なし)'}")

    if HAS_NUMPY:
        result = convert_numpy(img, color, args.gamma, args.invert)
    else:
        result = convert_pure_pil(img, color, args.gamma, args.invert)

    if not args.output.lower().endswith(".png"):
        print("Warning: 出力ファイルは PNG 形式を推奨します (Alpha チャンネルを保持するため)", file=sys.stderr)

    result.save(args.output)
    print(f"出力: {args.output}")


if __name__ == "__main__":
    main()

これを元に、

$ python3 ./scripts/to-monochrome-alpha.py 元画像 出力先パス --gamma 1.5

# もしくは
$ python3 ./scripts/to-monochrome-alpha.py 元画像 出力先パス --gamma 1.5 --invert

として、画像の濃さを Alpha 値として書き出せば、完了です。 あとはこれを monochrome 画像として設定することで、この通り:

キレイなアイコンになりました

満足のいくアイコンとなりました。ということでめでたしめでたし。

参考: