Search code examples
androidkotlinbitmapandroid-canvasandroid-bitmap

Android Bitmap pixels become slightly rectangular for larger width/height ratios


I am working on a pixel art editor for Android, and I noticed that when I create Bitmaps with more obscure width/height ratios, for example 3x300 or 5x90 (or the other way round) the pixels become slightly rectangular.

I've tried for a week or two to find out what exactly I am doing 'wrong' with the sizing calculations, but I have no idea how to fix this issue. This issue is not recreatable when creating Bitmaps with similar width/height ratios, for example a 50x40 or 90x80.

Below is the code that handles the sizing of the Rect in which we draw the Bitmap on.

Since some people had issues understanding the code, I will try to explain it. First of all, the ratio gives us the scaling factor in which we should multiply the base width/height so that our bitmap appears as expected.

For example, let's say the user selected a 5x10 (width is 5, and height is 10) bitmap, the height is larger than the width so the ratio will be 5/10 which is 0.5. now, the width remains the same, all we need to really scale is the height, so we take the height of the container and multiply that by 0.5 to get our desired result, et cetera. This is my best effort at explaining how the view is sized.

private fun setBoundingRect() {
    val ratio = if (bitmapWidth > bitmapHeight) {
        bitmapHeight.toDouble() / bitmapWidth.toDouble()
    } else {
        bitmapWidth.toDouble() / bitmapHeight.toDouble()
    }

    val rectW: Int = if (bitmapWidth > bitmapHeight) {
        width
    } else if (bitmapHeight > bitmapWidth) {
        (height * ratio).toInt()
    } else {
        width
    }

    val rectH: Int = if (bitmapWidth > bitmapHeight)  {
        (width * ratio).toInt()
    } else if (bitmapHeight > bitmapWidth) {
        height
    } else {
        width
    }

    val canvasCenter = Point(width / 2, height / 2)

    val left = canvasCenter.x - rectW / 2
    val top = canvasCenter.y - rectH / 2
    val right = canvasCenter.x + rectW / 2
    val bottom = canvasCenter.y + rectH / 2

    boundingRect = Rect(left, top, right, bottom)
}

For the most part, it works well.

onDraw method:

override fun onDraw(canvas: Canvas) {
    if (::drawingViewBitmap.isInitialized) {
        canvas.drawRect(boundingRect, PaintData.rectPaint)
        canvas.drawBitmap(drawingViewBitmap, null, boundingRect, null)
        drawGrid(canvas)
    }
}

Below is a demo of a 3x150 project, and as you can see, the pixels are 'rectangular', and it is quite evident:

enter image description here

I've tried to figure out what exactly I am doing wrong in my sizing calculations, which I think is where the issue is stemming from, but I haven't been able to figure it out.

Full code: https://github.com/therealbluepandabear/RevampedPixelGridView


Solution

  • The aspect ratio of your Rect varies depending the AR of the bitmap, but also on the size and aspect ratio of your View (runnable example):

    calculated rect width as 16 px, actual 16.62
    View size: 876 x 831
    Bitmap W/H ratio: 0.02
    Rect W/H ratio: 0.019277109
    
    calculated rect width as 19 px, actual 19.16
    View size: 887 x 958
    Bitmap W/H ratio: 0.02
    Rect W/H ratio: 0.018789144
    
    calculated rect width as 19 px, actual 19.96
    View size: 888 x 998
    Bitmap W/H ratio: 0.02
    Rect W/H ratio: 0.018036073
    
    calculated rect width as 17 px, actual 17.26
    View size: 936 x 863
    Bitmap W/H ratio: 0.02
    Rect W/H ratio: 0.018561484
    

    So your Rect's aspect ratio can be out by as much as 10% compared to the original bitmap at these View size - much more if the View is smaller.


    It's happening because you're relying on integer rounding to calculate the short dimension (those calculated rect widths up there are the result of toInt() on the actual float calculations). That introduces some error, and because the values you're working with are so small that becomes significant.

    You could improve it just by using roundToInt() instead of toInt(), so it can at least hit the closest integer every time:

    calculated rect width as 18 px, actual 17.900000000000002
    View size: 900 x 895
    Bitmap W/H ratio: 0.02
    Rect W/H ratio: 0.020134227
    
    calculated rect width as 18 px, actual 18.080000000000002
    View size: 943 x 904
    Bitmap W/H ratio: 0.02
    Rect W/H ratio: 0.019911505
    
    calculated rect width as 19 px, actual 19.080000000000002
    View size: 923 x 954
    Bitmap W/H ratio: 0.02
    Rect W/H ratio: 0.018867925
    
    calculated rect width as 20 px, actual 19.68
    View size: 981 x 984
    Bitmap W/H ratio: 0.02
    Rect W/H ratio: 0.020325202
    

    Or use floating-point calculations and create a RectF instead (there are drawBitmap and drawRect calls that use them):

    fun setBoundingRect() {    
        val rectW =
            if (bitmapWidth > bitmapHeight) width.toFloat()
            else (bitmapWidth.toFloat() / bitmapHeight) * height
        
        val rectH =
            if (bitmapHeight > bitmapWidth) height.toFloat()
            else (bitmapHeight.toFloat() / bitmapWidth) * width
        
        
        val canvasCenter = Point(width / 2, height / 2)
    
        val left = canvasCenter.x - rectW / 2
        val top = canvasCenter.y - rectH / 2
        val right = canvasCenter.x + rectW / 2
        val bottom = canvasCenter.y + rectH / 2
    
        boundingRect = RectF(left, top, right, bottom)
    }
    
    View size: 819 x 897
    Bitmap W/H ratio: 0.02
    Rect W/H ratio: 0.020000003
    
    View size: 987 x 996
    Bitmap W/H ratio: 0.02
    Rect W/H ratio: 0.019999983
    

    Or you could calculate a more appropriate height/width instead of just using the full height or width of the View, so that when you multiply it by ratio you get a round number (or close to it) for the other dimension. Right now the accuracy is just down to coincidence, how well the numbers match up