Androidでのビットマップのマスキング





はじめに



Android用に開発する場合、画像をマスクするタスクが頻繁に発生します。 ほとんどの場合、写真の角を丸くするか、画像を完全に丸くする必要があります。 ただし、より複雑な形式のマスクが使用される場合があります。



この記事では、このような問題を解決するためにAndroid開発者の兵器庫で利用可能なツールを分析し、最も成功したものを選択したいと思います。 この記事は、主に、サードパーティのライブラリを使用せずに、マスキングを手動で実装する必要がある場合に役立ちます。



読者はAndroid開発の経験があり、Canvas、Drawable、およびBitmapクラスに精通していると思います。



この記事で使用されているコードはGitHubにあります



問題の声明



ビットマップオブジェクトによって表される2つの画像があるとします。 それらの1つには元の画像が含まれ、2番目にはアルファチャネルにマスクが含まれています。 マスクを適用して画像を表示する必要があります。



通常、マスクはリソースに保存され、イメージはネットワーク経由でダウンロードされますが、この例では、両方のイメージが次のコードでリソースからダウンロードされます。



private void loadImages() { mPictureBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.picture); mMaskBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.mask_circle).extractAlpha(); }
      
      





.extractAlpha()



注意して.extractAlpha()



。この呼び出しは、ALPHA_8構成のビットマップを作成します。つまり、ピクセルごとに1バイトが消費され、このピクセルの透明度がエンコードされます。 マスクの色情報はペイロードを運ばず、捨てることができるため、この形式でマスクを保存することは非常に有益です。



画像がアップロードされたので、楽しい部分に進むことができます-マスキング。 これにはどのツールを使用できますか?



PorterDuffモード



提案されたソリューションの1つは、キャンバス(Canvas)でのイメージオーバーレイのPorterDuffモードの使用です。 それが何であるかをリフレッシュしましょう。



理論



表記法を導入します( 標準のように ):





モードは、Dc、Da、Sa、Scに応じてDa 'およびDc'が決定されるルールによって決定されます。



したがって、各ピクセルに4つのパラメーターがあります。 これらの4つのパラメーターから最終画像のピクセルの色と透明度を取得する公式は、ブレンドモードの説明です。



[Da '、Dc'] = f(Dc、Da、Sa、Sc)



たとえば、DST_INモードの場合、



Da '= Sa

Dc '= Sa・Dc



または、コンパクト表記[Da '、Dc'] = [Sa・Da、Sa・Dc]。 Androidのドキュメントでは、次のようになっています









Googleから過度に簡潔なドキュメントへのリンクを提供していただければ幸いです。 予備的な説明がなければ、これを熟考すると、開発者はしばしばst迷に陥ります: developer.android.com/reference/android/graphics/PorterDuff.Mode.html



しかし、これらの公式に従って最終画像がどのように見えるかを頭の中で理解することは非常に面倒です。 ブレンドモードにこのようなチートシートを使用すると、はるかに便利です。







このチートシートから興味のあるSRC_INおよびDST_INモードをすぐに確認できます。 実際、これらはキャンバスの不透明な領域とオーバーレイ画像の交差部分であり、DST_INはキャンバスの色をそのままにして、SRC_INは色を変更します。 画像が最初にキャンバスに描かれた場合は、DST_INを選択します。 マスクがもともとキャンバスにペイントされていた場合は、SRC_INを選択します。



すべてが明らかになったので、コードを書くことができます。



SRC_IN



Stackoverflow.comには、PorterDuffを使用するときにバッファー用のメモリを割り当てることが推奨される回答があります。 場合によっては、onDrawを呼び出すたびにこれを行うことをお勧めします。 もちろん、これは非常に非効率的です。 onDrawのヒープ上のメモリ割り当てをまったく回避するようにしてください。 ここでBitmap.createBitmapを見るのは驚くべきことですが、これには数メガバイトのメモリが必要になる場合があります。 簡単な例:ARGB形式の640 * 640の画像は、メモリで1.5 MB以上を占有します。



これを回避するには、バッファを事前に割り当てて、onDraw呼び出しで再利用できます。

SRC_INモードを使用するDrawableの例を次に示します。 バッファのメモリは、Drawableのサイズを変更するときに割り当てられます。



 public class MaskedDrawablePorterDuffSrcIn extends Drawable { private Bitmap mPictureBitmap; private Bitmap mMaskBitmap; private Bitmap mBufferBitmap; private Canvas mBufferCanvas; private final Paint mPaintSrcIn = new Paint(); public MaskedDrawablePorterDuffSrcIn() { mPaintSrcIn.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); } public void setPictureBitmap(Bitmap pictureBitmap) { mPictureBitmap = pictureBitmap; } public void setMaskBitmap(Bitmap maskBitmap) { mMaskBitmap = maskBitmap; } @Override protected void onBoundsChange(Rect bounds) { super.onBoundsChange(bounds); final int width = bounds.width(); final int height = bounds.height(); if (width <= 0 || height <= 0) { return; } mBufferBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); mBufferCanvas = new Canvas(mBufferBitmap); } @Override public void draw(Canvas canvas) { if (mPictureBitmap == null || mMaskBitmap == null) { return; } mBufferCanvas.drawBitmap(mMaskBitmap, 0, 0, null); mBufferCanvas.drawBitmap(mPictureBitmap, 0, 0, mPaintSrcIn); //dump the buffer canvas.drawBitmap(mBufferBitmap, 0, 0, null); }
      
      





上記の例では、最初にマスクがバッファキャンバスに描画され、次にSRC_INモードで画像が描画されます。



注意深い読者は、このコードが最適ではないことに気付くでしょう。 描画呼び出しごとにバッファキャンバスを再描画するのはなぜですか? 結局のところ、何かが変更された場合にのみこれを行うことができます。



最適化されたコード
 public class MaskedDrawablePorterDuffSrcIn extends MaskedDrawable { private Bitmap mPictureBitmap; private Bitmap mMaskBitmap; private Bitmap mBufferBitmap; private Canvas mBufferCanvas; private final Paint mPaintSrcIn = new Paint(); public static MaskedDrawableFactory getFactory() { return new MaskedDrawableFactory() { @Override public MaskedDrawable createMaskedDrawable() { return new MaskedDrawablePorterDuffSrcIn(); } }; } public MaskedDrawablePorterDuffSrcIn() { mPaintSrcIn.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); } @Override public void setPictureBitmap(Bitmap pictureBitmap) { mPictureBitmap = pictureBitmap; redrawBufferCanvas(); } @Override public void setMaskBitmap(Bitmap maskBitmap) { mMaskBitmap = maskBitmap; redrawBufferCanvas(); } @Override protected void onBoundsChange(Rect bounds) { super.onBoundsChange(bounds); final int width = bounds.width(); final int height = bounds.height(); if (width <= 0 || height <= 0) { return; } if (mBufferBitmap != null && mBufferBitmap.getWidth() == width && mBufferBitmap.getHeight() == height) { return; } mBufferBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); //that's too bad mBufferCanvas = new Canvas(mBufferBitmap); redrawBufferCanvas(); } private void redrawBufferCanvas() { if (mPictureBitmap == null || mMaskBitmap == null || mBufferCanvas == null) { return; } mBufferCanvas.drawBitmap(mMaskBitmap, 0, 0, null); mBufferCanvas.drawBitmap(mPictureBitmap, 0, 0, mPaintSrcIn); } @Override public void draw(Canvas canvas) { //dump the buffer canvas.drawBitmap(mBufferBitmap, 0, 0, null); } @Override public void setAlpha(int alpha) { mPaintSrcIn.setAlpha(alpha); } @Override public void setColorFilter(ColorFilter cf) { //Not implemented } @Override public int getOpacity() { return PixelFormat.UNKNOWN; } @Override public int getIntrinsicWidth() { return mMaskBitmap != null ? mMaskBitmap.getWidth() : super.getIntrinsicWidth(); } @Override public int getIntrinsicHeight() { return mMaskBitmap != null ? mMaskBitmap.getHeight() : super.getIntrinsicHeight(); } }
      
      







DST_IN



SRC_INとは異なり、DST_INを使用する場合は、描画順序を変更する必要があります。最初に、キャンバスとマスクの上に画像が描画されます。 前の例からの変更は次のとおりです。



 mPaintDstIn.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN)); mBufferCanvas.drawBitmap(mPictureBitmap, 0, 0, null); mBufferCanvas.drawBitmap(mMaskBitmap, 0, 0, mPaintDstIn);
      
      





奇妙なことに、マスクがALPHA_8形式で提示されている場合、このコードは期待した結果を与えません。 非効率的な形式のARGB_8888で表示される場合は、すべて問題ありません。 stackoverflow.com の質問は現在未回答のままです。 誰かが理由を知っている場合-コメントで知識を共有してください。



CLEAR + DST_OVER



上記の例では、バッファのメモリはDrawableサイズが変更されたときにのみ割り当てられました。これは、毎回割り当てるよりもはるかに優れています。



しかし、考えてみると、場合によっては、バッファをまったく割り当てずに、描画で渡されたキャンバスにすぐに描画できます。 何かがすでに描かれていることを覚えておいてください。



これを行うには、キャンバスでCLEARモードを使用してマスクの形に穴を開け、DST_OVERモードで絵を描きます。比fig的に言えば、キャンバスの下に絵を入れます。 この穴を通して写真を見ることができ、効果はまさに私たちが必要とするものです。



このようなトリックは、マスクと画像に半透明の領域が含まれておらず、完全に透明または完全に不透明なピクセルのみが含まれていることがわかっている場合に使用できます。



コードは次のようになります。



 mPaintDstOver.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OVER)); mPaintClear.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); //draw the mask with clear mode canvas.drawBitmap(mMaskBitmap, 0, 0, mPaintClear); //draw picture with dst over mode canvas.drawBitmap(mPictureBitmap, 0, 0, mPaintDstOver);
      
      





このソリューションには透明性に関する問題があります。 setAlphaメソッドを実装する場合は、Drawableの下のキャンバスに描かれたものではなく、ウィンドウの背景が画像を照らします。 画像の比較:









CLEAR + DST_OVERを半透明性と組み合わせて使用​​すると、左側にあるはずのとおり、右側にあることがわかります。



ご覧のとおり、AndroidでのPorterDuffモードの使用は、過剰なメモリの割り当てまたは使用制限に関連付けられています。 幸いなことに、これらすべての問題を回避する方法があります。 BitmapShaderを使用するだけです。



ビットマップシェーダー



通常、シェーダーが言及されるとき、それらはOpenGLを思い出します。 ただし、AndroidでBitmapShaderを使用する場合、開発者がこの分野の知識を持っている必要はありません。 実際、android.graphics.Shader実装は、各ピクセルの色を決定するアルゴリズム、つまりピクセルシェーダーを記述しています。



それらの使用方法は? 非常に簡単:ペイントでシェーダーをロードすると、このペイントを使用して描画されるすべてのものがシェーダーからピクセルの色を取ります。 このパッケージには、グラデーションを描画し、他のシェーダーと(このタスクのコンテキストで最も役立つ)BitmapShaderを組み合わせて、Bitmapを使用して初期化するシェーダー実装があります。 このようなシェーダーは、初期化中に転送されたビットマップから対応するピクセルの色を返します。



ドキュメントには重要な説明があります。ビットマップ以外のすべてをシェーダーで描画できます。 実際、ビットマップがALPHA_8形式の場合、シェーダーを使用してそのようなビットマップをレンダリングするとき、すべてが正常に機能します。 マスクはこの形式になっているため、花の画像を使用するシェーダーを使用してマスクを表示してみましょう。



手順:





 public void setPictureBitmap(Bitmap src) { mPictureBitmap = src; mBitmapShader = new BitmapShader(mPictureBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT); mPaintShader.setShader(mBitmapShader); } public void draw(Canvas canvas) { if (mPaintShader == null || mMaskBitmap == null) { return; } canvas.drawBitmap(mMaskBitmap, 0, 0, mPaintShader); }
      
      





すべてが非常に簡単ですよね? 実際、マスクと画像のサイズが一致しない場合、期待したものが正確に表示されません。 マスクは、 Shader.TileMode.REPEAT



モードに対応する画像でShader.TileMode.REPEAT



ます。



画像のサイズをマスクのサイズに縮小するには、 android.graphics.Shader#setLocalMatrix methodを使用します。このメソッドに変換マトリックスを転送する必要があります。 幸いなことに、分析ジオメトリのコースを覚える必要はありません。android.graphics.Matrixクラスには、マトリックスを形成するための便利なメソッドが含まれています。 画像がプロポーションの歪みなしにマスクに完全に収まるようにシェーダーを圧縮し、画像の中心とマスクが揃うように移動します。



 private void updateScaleMatrix() { if (mPictureBitmap == null || mMaskBitmap == null) { return; } int maskW = mMaskBitmap.getWidth(); int maskH = mMaskBitmap.getHeight(); int pictureW = mPictureBitmap.getWidth(); int pictureH = mPictureBitmap.getHeight(); float wScale = maskW / (float) pictureW; float hScale = maskH / (float) pictureH; float scale = Math.max(wScale, hScale); Matrix matrix = new Matrix(); matrix.setScale(scale, scale); matrix.postTranslate((maskW - pictureW * scale) / 2f, (maskH - pictureH * scale) / 2f); mBitmapShader.setLocalMatrix(matrix); }
      
      





また、シェーダーを使用すると、Drawableの透明度を変更し、ColorFilterを設定するメソッドを簡単に実装できます。 同じ名前のシェーダーメソッドを呼び出すだけで十分です。



最終コード
 public class MaskedDrawableBitmapShader extends Drawable { private Bitmap mPictureBitmap; private Bitmap mMaskBitmap; private final Paint mPaintShader = new Paint(); private BitmapShader mBitmapShader; public void setMaskBitmap(Bitmap maskBitmap) { mMaskBitmap = maskBitmap; updateScaleMatrix(); } public void setPictureBitmap(Bitmap src) { mPictureBitmap = src; mBitmapShader = new BitmapShader(mPictureBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT); mPaintShader.setShader(mBitmapShader); updateScaleMatrix(); } @Override public void draw(Canvas canvas) { if (mPaintShader == null || mMaskBitmap == null) { return; } canvas.drawBitmap(mMaskBitmap, 0, 0, mPaintShader); } private void updateScaleMatrix() { if (mPictureBitmap == null || mMaskBitmap == null) { return; } int maskW = mMaskBitmap.getWidth(); int maskH = mMaskBitmap.getHeight(); int pictureW = mPictureBitmap.getWidth(); int pictureH = mPictureBitmap.getHeight(); float wScale = maskW / (float) pictureW; float hScale = maskH / (float) pictureH; float scale = Math.max(wScale, hScale); Matrix matrix = new Matrix(); matrix.setScale(scale, scale); matrix.postTranslate((maskW - pictureW * scale) / 2f, (maskH - pictureH * scale) / 2f); mBitmapShader.setLocalMatrix(matrix); } @Override public void setAlpha(int alpha) { mPaintShader.setAlpha(alpha); } @Override public void setColorFilter(ColorFilter cf) { mPaintShader.setColorFilter(cf); } @Override public int getOpacity() { return PixelFormat.UNKNOWN; } @Override public int getIntrinsicWidth() { return mMaskBitmap != null ? mMaskBitmap.getWidth() : super.getIntrinsicWidth(); } @Override public int getIntrinsicHeight() { return mMaskBitmap != null ? mMaskBitmap.getHeight() : super.getIntrinsicHeight(); } }
      
      







私の意見では、これは問題に対する最も成功した解決策です。バッファ割り当ては不要であり、透明性に問題はありません。 さらに、マスクが単純な幾何学形状である場合、マスク付きのビットマップのダウンロードを拒否して、プログラムでマスクを描画できます。 これにより、マスクをビットマップとして保存するために必要なメモリが節約されます。



たとえば、この記事で例として使用するマスクは、簡単に描画できる非常に単純な幾何学的図形です。



コード例
 public class FixedMaskDrawableBitmapShader extends Drawable { private Bitmap mPictureBitmap; private final Paint mPaintShader = new Paint(); private BitmapShader mBitmapShader; private Path mPath; public void setPictureBitmap(Bitmap src) { mPictureBitmap = src; mBitmapShader = new BitmapShader(mPictureBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT); mPaintShader.setShader(mBitmapShader); mPath = new Path(); mPath.addOval(0, 0, getIntrinsicWidth(), getIntrinsicHeight(), Path.Direction.CW); Path subPath = new Path(); subPath.addOval(getIntrinsicWidth() * 0.7f, getIntrinsicHeight() * 0.7f, getIntrinsicWidth(), getIntrinsicHeight(), Path.Direction.CW); mPath.op(subPath, Path.Op.DIFFERENCE); } @Override public void draw(Canvas canvas) { if (mPictureBitmap == null) { return; } canvas.drawPath(mPath, mPaintShader); } @Override public void setAlpha(int alpha) { mPaintShader.setAlpha(alpha); } @Override public void setColorFilter(ColorFilter cf) { mPaintShader.setColorFilter(cf); } @Override public int getOpacity() { return PixelFormat.UNKNOWN; } @Override public int getIntrinsicWidth() { return mPictureBitmap != null ? mPictureBitmap.getWidth() : super.getIntrinsicWidth(); } @Override public int getIntrinsicHeight() { return mPictureBitmap != null ? mPictureBitmap.getHeight() : super.getIntrinsicHeight(); } }
      
      







シェーダーを使用して何でも描画できるため、たとえば次のようにテキストを描画できます。



 public void setPictureBitmap(Bitmap src) { mPictureBitmap = src; mBitmapShader = new BitmapShader(mPictureBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT); mPaintShader.setShader(mBitmapShader); mPaintShader.setTextSize(getIntrinsicHeight()); mPaintShader.setStyle(Paint.Style.FILL); mPaintShader.setTextAlign(Paint.Align.CENTER); mPaintShader.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); } @Override public void draw(Canvas canvas) { if (mPictureBitmap == null) { return; } canvas.drawText("A", getIntrinsicWidth() / 2, getIntrinsicHeight() * 0.9f, mPaintShader); }
      
      





結果:







RoundedBitmapDrawable



RoundedBitmapDrawableクラスがサポートライブラリに存在することを知っておくと役立ちます。 エッジを丸くするか、画像を完全に丸くする必要がある場合に便利です。 内部的には、BitmapShaderが使用されます。



性能



上記のソリューションがパフォーマンスにどのように影響するかを見てみましょう。 このために、100個の要素を持つRecyclerViewを使用しました。 GPUモニターグラフは、かなり生産的なスマートフォン(Moto X Style)で高速スクロールで撮影されました。



横軸に沿ったグラフ-縦軸に沿った時間-各フレームの描画に費やされたミリ秒数を思い出させてください。 理想的には、チャートは60 FPSに対応する緑の線の下に配置する必要があります。





プレーンBitmapDrawable(マスキングなし)





SRC_IN





ビットマップシェーダー



BitmapShaderを使用すると、一般的なマスキングなしと同じ高フレームレートを実現できることがわかります。 SRC_INソリューションは十分に生産的であるとは見なせませんが、高速スクロール中にインターフェイスの速度が大幅に低下します。これは、グラフで確認されています。



結論



私の意見では、BitmapShaderを使用するアプローチの利点は明らかです。バッファにメモリを割り当てる必要はなく、優れた柔軟性、半透明性のサポート、高性能が必要です。

このアプローチがライブラリの実装で使用されることは驚くことではありません。



コメントであなたの考えを共有してください!



stackoverflow.comがあなたと共にありますように!



All Articles