Search code examples
androidcanvasbitmap

Android Bitmap: Pixels appear to be rectangular when zooming (possible Bitmap artifacts)


I am creating a pixel art editor for Android, and to do this, I am using a Canvas with a Bitmap.

Here is an excerpt of some of my code (MyCanvasView) which handles the majority of pixel art functionality:

package com.realtomjoney.pyxlmoose.customviews.mycanvasview

import android.content.Context
import android.view.MotionEvent
import android.view.View
import androidx.lifecycle.LifecycleOwner
import com.realtomjoney.pyxlmoose.listeners.CanvasFragmentListener
import com.realtomjoney.pyxlmoose.models.BitmapAction
import com.realtomjoney.pyxlmoose.models.XYPosition
import android.graphics.*
import com.realtomjoney.pyxlmoose.activities.canvas.canvasInstance
import com.realtomjoney.pyxlmoose.models.BitmapActionData


class MyCanvasView (context: Context, private var spanCount: Int) : View(context) {
    lateinit var extraCanvas: Canvas
    lateinit var extraBitmap: Bitmap

    private var scaleWidth = 0f
    private var scaleHeight = 0f

    var prevX: Int? = null
    var prevY: Int? = null

    val bitmapActionData: MutableList<BitmapAction> = mutableListOf()
    var currentBitmapAction: BitmapAction? = null

    var lifecycleOwner: LifecycleOwner? = null

    private lateinit var caller: CanvasFragmentListener

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)

        caller = context as CanvasFragmentListener

        if (::extraBitmap.isInitialized) extraBitmap.recycle()

        extraBitmap = Bitmap.createBitmap(spanCount, spanCount, Bitmap.Config.ARGB_8888)
        extraCanvas = Canvas(extraBitmap)
    }

    override fun dispatchTouchEvent(event: MotionEvent): Boolean {
        val coordinateX = (event.x / scaleWidth).toInt()
        val coordinateY = (event.y / scaleWidth).toInt()

        if (currentBitmapAction == null) {
            currentBitmapAction = BitmapAction(mutableListOf())
        }

        when (event.actionMasked) {
            MotionEvent.ACTION_MOVE -> {
                if (coordinateX in 0 until spanCount && coordinateY in 0 until spanCount) {
                    caller.onPixelTapped(extraBitmap, XYPosition(coordinateX, coordinateY))
                } else {
                    prevX = null
                    prevY = null
                }
            }
            MotionEvent.ACTION_DOWN -> {
                if (coordinateX in 0 until spanCount && coordinateY in 0 until spanCount) {
                    caller.onPixelTapped(extraBitmap, XYPosition(coordinateX, coordinateY))
                } else {
                    prevX = null
                    prevY = null
                }
            }
            MotionEvent.ACTION_UP -> {
                caller.onActionUp()
            }
        }

        invalidate()

        return true
    }

    fun undo() {
        if (bitmapActionData.size > 0) {
            if (!bitmapActionData.last().isFilterBased) {
                for ((key, value) in bitmapActionData.last().actionData.distinctBy { it.xyPosition }) {
                    extraBitmap.setPixel(key.x, key.y, value)
                }
            } else {
                for ((key, value) in bitmapActionData.last().actionData) {
                    extraBitmap.setPixel(key.x, key.y, value)
                }
            }

            invalidate()
            bitmapActionData.removeLast()
        }
    }

    fun clearCanvas() {
        for (i_1 in 0 until extraBitmap.width) {
            for (i_2 in 0 until extraBitmap.height) {
                extraBitmap.setPixel(i_1, i_2, Color.TRANSPARENT)
            }
        }
        invalidate()
        bitmapActionData.clear()
    }


    private fun getResizedBitmap(bm: Bitmap, newHeight: Int, newWidth: Int): Bitmap? {
        val width = bm.width
        val height = bm.height
        val scaleWidth = newWidth.toFloat() / width
        val scaleHeight = newHeight.toFloat() / height

        this.scaleWidth = scaleWidth
        this.scaleHeight = scaleHeight

        val matrix = Matrix()
        matrix.postScale(scaleWidth, scaleHeight)

        return Bitmap.createBitmap(bm, 0, 0, width, height, matrix, false)
    }

    override fun onDraw(canvas: Canvas) {
        canvas.drawBitmap(getResizedBitmap(extraBitmap, this.width, this.width)!!, 0f, 0f, null)
    }

}

('MyCanvasView' is then loaded into a Fragment which resides inside CanvasActivity.)

Pixels are set simply with the Bitmap.setPixel method, if you didn't see from that code. And you might have already seen that pixels are connected to each other with a line algorithm to give the user the illusion of infinite hardware input rate.

I seem to be having quite a strange problem (this is the worst bug, as my app has numerous other bugs as well as this one).

Say I create a canvas with a span count of around 100 and draw some stuff with the pencil tool:

enter image description here

As you can see from the picture - the pixels render fine when you zoom in, and they appear to be a perfect square.

Now, suppose I create a canvas with a span count of around 670 (note that this is a rare edge case, but everything still needs to be functioning properly - even for larger Bitmaps) and draw some stuff:

![enter image description here

It appears relatively fine from the outside, but once you zoom in:

enter image description here

enter image description here

..the pixels appear rectangular, and it overall looks very strange.

For canvas sizes of more than 1500x1500 (yes I know, it is a very rare edge case) the artifacts are even more visible, and spaces even appear between each pixel:

enter image description here

I've spoken to a couple of people who have experience with pixel art editors and they couldn't tell me why this is happening - but they assume it has something to do with the getResizedBitmap method - although I'm not entirely sure whether that's true or not.

This problem isn't major per se - as it's a mobile editor most users won't be using canvas sizes of 670x670, but it's still worth fixing in my opinion. I'm doing most things by the book, so I'm confused why these artifacts are appearing.

What might be the main cause of this issue?


Solution

  • Why are you resizing the bitmap? Why are you creating a new bitmap every time onDraw is called?

    Every time you resize the bitmap, you cause a magnification or minification algorithm to be applied. That's probably the cause of the distortions you're seeing.

    You should create one bitmap at start, and render that in the onDraw method. The matrix you're calculating in getResizedBitmap should instead be used as an argument to this function