Photoruction工事中!

Photoructionの開発ブログです!

Android Canvas で画像が劣化しない問題を完全解決する ― Bitmap 縮小の最適手法

こんにちは。Androidエンジニアの斎藤です。

はじめに

AndroidCanvas に Bitmap を描画していると、

  • 画像がぼやける
  • 線が潰れる
  • 小さいアイコンが滲む

といった 劣化が発生する問題 に悩むことがありませんか?

特に縮小幅が大きいケースで顕著に劣化します。

フォトラクションでも電子黒板の描画に Canvas を利用しており、この黒板に撮影に関連した画像を描画することがあります。しかしCanvasへの画像を描画するにあたりサイズを縮小すると画像が劣化するケースがありました。

元の画像
Canvasに描画した画像

本記事では、この劣化がなぜ起きるのか、そして Canvas 描画時にも劣化させない最適解を紹介します。

なぜ Canvas に Bitmap を描画すると劣化するのか?

Canvas の描画処理の中で、Android は暗黙的に、Bilinear(バイリニア)補間でスケールしてから描画を行っています。

Bilinear は滑らかに縮小できる反面、以下の欠点があります:

  • 細い線やドット絵がにじむ
  • 極端な縮小で急激に情報が失われる
  • 特に図形、ピクトグラムなどで潰れが出る

Canvas に渡す時点で 元Bitmapを縮小していない と、Canvas 内部の処理(skia)が「雑なスケール」をしてしまい、結果的に劣化します。

Bilinear(バイリニア)補間とは

画像の拡大・縮小は「補間(interpolation)」という処理で行われます。

補間は カーネル(kernel)” と呼ばれる数学的な関数を使って、

新しいピクセル値を、元画像の周囲のピクセルから計算する仕組みです。

カーネル(kernel)とは?

カーネルとは、「どの範囲のピクセルを、どれくらいの重みで参照して新しい値を作るか」を定義する関数のこと。

  • 小さいカーネル → 小さな範囲の平均(計算は速いが品質は低い)
  • 大きいカーネル → 広範囲の情報を使って賢く平均(計算は遅いが品質が高い)

代表的なカーネルを並べると品質と範囲の関係がわかります。

補間方法 カーネルの大きさ 特徴
Nearest Neighbor 1ピクセル “コピー”に近く荒れる。縮小には不向き
Bilinear 2×2 ピクセル 高速だが大幅縮小では情報不足
Bicubic 4×4 ピクセル 滑らかで美しいが計算コストが高い
Lanczos(三次 sinc) 6×6, 8×8… 最強クラスに綺麗だがとても重い

AndroidcreateScaledBitmap(filter = true)が使うのは Bilinear(2×2)です。

解決策:Canvas に渡す前に「質の良い縮小」を行う

Canvas 任せの縮小ではなく、自分で劣化を抑えた縮小処理を行うことが重要です。

ポイントはこの2つ:

  1. 段階的に縮小を行う(一気に1/3以下にしない)
  2. Bilinear 補間を使い、線が潰れないようにする

実際に劣化しない縮小処理の実装(高速 & 高品質)

今回紹介するのは、

  • 高速(最大2回の縮小)
  • ARGB_8888 の画質維持
  • メモリ効率も良い

という ビットマップ縮小アルゴリズム です。

/**
 * 高速かつシャープにBitmapを縮小する
 */
fun resizeBitmapSharpFast(source: Bitmap, targetWidth: Int, targetHeight: Int): Bitmap {
    var currentBitmap = if (source.config != Bitmap.Config.ARGB_8888) {
        source.copy(Bitmap.Config.ARGB_8888, false)
    } else {
        source
    }

    val currentWidth = currentBitmap.width
    val currentHeight = currentBitmap.height

    // まず一気に近似サイズ(ターゲットの2倍)まで縮小
    val approxWidth = targetWidth * 2
    val approxHeight = targetHeight * 2

    val firstStepBitmap = if (currentWidth > approxWidth && currentHeight > approxHeight) {
        val tmp = Bitmap.createScaledBitmap(currentBitmap, approxWidth, approxHeight, true)
        if (currentBitmap != source) currentBitmap.recycle()
        tmp
    } else {
        currentBitmap
    }

    // 最終サイズに縮小
    val finalBitmap = Bitmap.createScaledBitmap(firstStepBitmap, targetWidth, targetHeight, true)
    if (firstStepBitmap != currentBitmap) firstStepBitmap.recycle()
    if (currentBitmap != source) currentBitmap.recycle()

    return finalBitmap
}

Canvas に描画するコード例

val resizedIcon = resizeBitmapSharpFast(originalIcon, width, height)
canvas.drawBitmap(resizedIcon, x.toFloat(), y.toFloat(), null)

結果

線が潰れなくなりました

なぜ段階的縮小は「情報落ち」を抑えられるのか

AndroidCanvas 上で画像を小さく描画すると、細線が滲んだり、潰れてしまったりすることがあります。 これは「縮小時の情報落ち(エイリアシング)」が原因です。

一気に大幅縮小すると起こること:エイリアシング

たとえば “縮小率が大きいまま一気に縮小” すると次の問題が起きます。

  • 細い線(高周波成分)が平均化されて消える
  • ジャギー(ギザギザ)が出る
  • 細線の太さが不均一になる
  • 滲む

これは、画像に含まれる細かい情報(高周波成分)が縮小された先のピクセル数よりも多くなりすぎ、情報が正しく写り取れないためです。

これを エイリアシング(折り返し誤差) と呼びます。

なぜ「段階的縮小」だと改善するのか?

段階ごとに適度なローパス(平滑化)が入り、エイリアシングが起きにくくなるため

具体的には:

  • 小さめの縮小なら、単純な Bilinear 補間でも十分に平滑化を行える
  • 各段階で「細線が消えずに、やや太めに残る」
  • 次の縮小段階でも線が失われずに済む
  • 結果として 最終的な解像度に応じた自然な線の太さとエッジが残る

Canvas の画質劣化は完全に防げる

Canvas 描画時の劣化は Android のスケール処理が原因であり、回避するには次の3つが重要です。

  1. Canvas に渡す前に自前で縮小する
  2. 一気に縮小しない(段階縮小)
  3. 高品質縮小(Bilinear)を使う
株式会社フォトラクションでは一緒に働く仲間を募集しています

www.wantedly.com