3

I have a canvas where I draw two images of the same size, and I've implemented a touch listener where I "erase" one of them and I'd like to know if there's any possibility to know the % of visibility of the one that I'm "erasing".

val overlayImageLoaded = rememberAsyncImagePainter(
        model = overlayImage,
    )
    val baseImageLoaded = rememberAsyncImagePainter(
        model = baseImage
    )
    Canvas(modifier = modifier
        .size(220.dp)
        .clip(RoundedCornerShape(size = 16.dp))
        .pointerInteropFilter {
            when (it.action) {
                MotionEvent.ACTION_DOWN -> {
                    currentPath.moveTo(it.x, it.y)
                }

                MotionEvent.ACTION_MOVE -> {
                    onMovedOffset(it.x, it.y)
                }
            }
            true
        }) {

        with(overlayImageLoaded) {
            draw(size = Size(size.width, size.height))

        }

        movedOffset?.let {
            currentPath.addOval(oval = Rect(it, currentPathThickness))
        }

        clipPath(path = currentPath, clipOp = ClipOp.Intersect) {
            with(baseImageLoaded) {
                draw(size = Size(size.width, size.height))
            }
        }
    }

I have some ideas :

Since what I want is to know if the image have been erased at least 70% let's say I've thought about store the onMovedOffset into a list and since I know the size of my canvas and the path thickness I can do a calculus of what user have seen, but perhaps it's a bit overkill.

Also I've thought about getting the canvas draw as a bitmap every-time user moves and then have a method that compares bitmap with bitmap and check the % of equality.

The goal is to know wether the user have erased at least 70% of the image and it's not visible anymore.

StuartDTO
  • 783
  • 7
  • 26
  • 72

1 Answers1

3

Answer of this question is quite complex and involves many layers. There can be some optimization for threading to comparing pixels in background thread.

Step one create copy of original ImageBitmap to fit in Composable on screen. Since we erase pixels on screen we should use sc

// Pixels of scaled bitmap, we scale it to composable size because we will erase
// from Composable on screen
val originalPixels: IntArray = remember {
    val buffer = IntArray(imageWidth * imageHeight)
    Bitmap.createScaledBitmap(imageBitmap.asAndroidBitmap(), imageWidth, imageHeight, false)
        .asImageBitmap()
        .readPixels(
            buffer = buffer,
            startX = 0,
            startY = 0,
            width = imageWidth,
            height = imageHeight
        )

    buffer
}

val erasedBitmap: ImageBitmap = remember {
    Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888).asImageBitmap()
}

Second step is to create a androidx.compose.ui.graphics.Canvas(imageBitmap) to apply changes on imageBitmap as in this answer. Check this out to be familiar with how to manipulate Bitmap that is drawn to empty Bitmap

val canvas: Canvas = remember {
    Canvas(erasedBitmap)
}

Third step create paints to erase from Bitmap

val paint = remember {
    Paint()
}

val erasePaint = remember {
    Paint().apply {
        blendMode = BlendMode.Clear
        this.style = PaintingStyle.Stroke
        strokeWidth = 30f
    }
}

Forth step is to erase from Bitmap with gesture and calculate difference of original pixels with currently erased bitmap

canvas.apply {
    val nativeCanvas = this.nativeCanvas
    val canvasWidth = nativeCanvas.width.toFloat()
    val canvasHeight = nativeCanvas.height.toFloat()


    when (motionEvent) {

        MotionEvent.Down -> {
            erasePath.moveTo(currentPosition.x, currentPosition.y)
            previousPosition = currentPosition

        }
        MotionEvent.Move -> {

            erasePath.quadraticBezierTo(
                previousPosition.x,
                previousPosition.y,
                (previousPosition.x + currentPosition.x) / 2,
                (previousPosition.y + currentPosition.y) / 2

            )
            previousPosition = currentPosition
        }

        MotionEvent.Up -> {
            erasePath.lineTo(currentPosition.x, currentPosition.y)
            currentPosition = Offset.Unspecified
            previousPosition = currentPosition
            motionEvent = MotionEvent.Idle

            matchPercent = compareBitmaps(
                originalPixels,
                erasedBitmap,
                imageWidth,
                imageHeight
            )
        }
        else -> Unit
    }

    with(canvas.nativeCanvas) {
        drawColor(android.graphics.Color.TRANSPARENT, PorterDuff.Mode.CLEAR)

        drawImageRect(
            image = imageBitmap,
            dstSize = IntSize(canvasWidth.toInt(), canvasHeight.toInt()),
            paint = paint
        )

        drawPath(
            path = erasePath,
            paint = erasePaint
        )
    }
}

Fifth step is to compare original pixels with erased bitmap to determine in which percent they match

private fun compareBitmaps(
    originalPixels: IntArray,
    erasedBitmap: ImageBitmap,
    imageWidth: Int,
    imageHeight: Int
): Float {

    var match = 0f

    val size = imageWidth * imageHeight
    val erasedBitmapPixels = IntArray(size)

    erasedBitmap.readPixels(
        buffer = erasedBitmapPixels,
        startX = 0,
        startY = 0,
        width = imageWidth,
        height = imageHeight
    )

    erasedBitmapPixels.forEachIndexed { index, pixel: Int ->
        if (originalPixels[index] == pixel) {
            match++
        }
    }

    return 100f * match / size
}

Full Implementation

@Composable
fun EraseBitmapSample(imageBitmap: ImageBitmap, modifier: Modifier) {


    var matchPercent by remember {
        mutableStateOf(100f)
    }

    BoxWithConstraints(modifier) {

        // Path used for erasing. In this example erasing is faked by drawing with canvas color
        // above draw path.
        val erasePath = remember { Path() }

        var motionEvent by remember { mutableStateOf(MotionEvent.Idle) }
        // This is our motion event we get from touch motion
        var currentPosition by remember { mutableStateOf(Offset.Unspecified) }
        // This is previous motion event before next touch is saved into this current position
        var previousPosition by remember { mutableStateOf(Offset.Unspecified) }

        val imageWidth = constraints.maxWidth
        val imageHeight = constraints.maxHeight


        val drawImageBitmap = remember {
            Bitmap.createScaledBitmap(imageBitmap.asAndroidBitmap(), imageWidth, imageHeight, false)
                .asImageBitmap()
        }

        // Pixels of scaled bitmap, we scale it to composable size because we will erase
        // from Composable on screen
        val originalPixels: IntArray = remember {
            val buffer = IntArray(imageWidth * imageHeight)
            drawImageBitmap
                .readPixels(
                    buffer = buffer,
                    startX = 0,
                    startY = 0,
                    width = imageWidth,
                    height = imageHeight
                )

            buffer
        }

        val erasedBitmap: ImageBitmap = remember {
            Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888).asImageBitmap()
        }

        val canvas: Canvas = remember {
            Canvas(erasedBitmap)
        }

        val paint = remember {
            Paint()
        }

        val erasePaint = remember {
            Paint().apply {
                blendMode = BlendMode.Clear
                this.style = PaintingStyle.Stroke
                strokeWidth = 30f
            }
        }


        canvas.apply {
            val nativeCanvas = this.nativeCanvas
            val canvasWidth = nativeCanvas.width.toFloat()
            val canvasHeight = nativeCanvas.height.toFloat()


            when (motionEvent) {

                MotionEvent.Down -> {
                    erasePath.moveTo(currentPosition.x, currentPosition.y)
                    previousPosition = currentPosition

                }
                MotionEvent.Move -> {

                    erasePath.quadraticBezierTo(
                        previousPosition.x,
                        previousPosition.y,
                        (previousPosition.x + currentPosition.x) / 2,
                        (previousPosition.y + currentPosition.y) / 2

                    )
                    previousPosition = currentPosition
                }

                MotionEvent.Up -> {
                    erasePath.lineTo(currentPosition.x, currentPosition.y)
                    currentPosition = Offset.Unspecified
                    previousPosition = currentPosition
                    motionEvent = MotionEvent.Idle

                    matchPercent = compareBitmaps(
                        originalPixels,
                        erasedBitmap,
                        imageWidth,
                        imageHeight
                    )
                }
                else -> Unit
            }

            with(canvas.nativeCanvas) {
                drawColor(android.graphics.Color.TRANSPARENT, PorterDuff.Mode.CLEAR)



                drawImageRect(
                    image = drawImageBitmap,
                    dstSize = IntSize(canvasWidth.toInt(), canvasHeight.toInt()),
                    paint = paint
                )

                drawPath(
                    path = erasePath,
                    paint = erasePaint
                )
            }
        }

        val canvasModifier = Modifier.pointerMotionEvents(
            Unit,
            onDown = { pointerInputChange ->
                motionEvent = MotionEvent.Down
                currentPosition = pointerInputChange.position
                pointerInputChange.consume()
            },
            onMove = { pointerInputChange ->
                motionEvent = MotionEvent.Move
                currentPosition = pointerInputChange.position
                pointerInputChange.consume()
            },
            onUp = { pointerInputChange ->
                motionEvent = MotionEvent.Up
                pointerInputChange.consume()
            },
            delayAfterDownInMillis = 20
        )

        Image(
            modifier = canvasModifier
                .clipToBounds()
                .drawBehind {
                    val width = this.size.width
                    val height = this.size.height

                    val checkerWidth = 10.dp.toPx()
                    val checkerHeight = 10.dp.toPx()

                    val horizontalSteps = (width / checkerWidth).toInt()
                    val verticalSteps = (height / checkerHeight).toInt()

                    for (y in 0..verticalSteps) {
                        for (x in 0..horizontalSteps) {
                            val isGrayTile = ((x + y) % 2 == 1)
                            drawRect(
                                color = if (isGrayTile) Color.LightGray else Color.White,
                                topLeft = Offset(x * checkerWidth, y * checkerHeight),
                                size = Size(checkerWidth, checkerHeight)
                            )
                        }
                    }
                }
                .matchParentSize()
                .border(2.dp, Color.Green),
            bitmap = erasedBitmap,
            contentDescription = null,
            contentScale = ContentScale.FillBounds
        )
    }

    Text("Original Bitmap")

    Image(
        modifier = modifier,
        bitmap = imageBitmap,
        contentDescription = null,
        contentScale = ContentScale.FillBounds
    )

    Text("Bitmap match $matchPercent", color = Color.Red, fontSize = 22.sp)

}

Result

enter image description here

Thracian
  • 43,021
  • 16
  • 133
  • 222
  • The snippet above is i use for detecting color of touch point in this section. https://github.com/SmartToolFactory/Jetpack-Compose-Tutorials#5-10-1-image-touch-detection – Thracian Nov 25 '22 at 19:43
  • Thanks for answering, the thing is that I'm following this [example](https://androidexample365.com/scratch-card-effect-in-jetpack-compose/) and what I'm looking for is to have a callback when there's let's say 70% of the base image visible, so I can remove the overlay one and show the base one – StuartDTO Nov 28 '22 at 15:45
  • Unfortunately there is no such callback to show which percentage is being erased. That example you have is not how you solve it. You can check out my answer here which is the same thing you manipulate pixels on canvas. https://stackoverflow.com/a/73025165/5457853. What you actually be doing is the one i posted above changing bitmap pixels and comparing them with original image – Thracian Nov 28 '22 at 15:49
  • I finally figured out how to successfully change pixels on bitmap as in this link. https://stackoverflow.com/questions/72168588/jetpack-compose-androidx-compose-ui-graphics-canvas-not-refreshing-correctly-for I will post an answer for my question then to yours. – Thracian Nov 28 '22 at 15:50
  • @StuartDTO finally finished this answer and added some flavor to draw checker background to show it's being erased. I finally figured out my question as well after 6 months. With Canvas(bitmap) you can draw and change pixels of Bitmap – Thracian Nov 29 '22 at 15:15
  • Hey @Thracian I'm gonna check if it fits my needs ! awesome answer though – StuartDTO Dec 01 '22 at 10:14
  • Hello Thracian I'm going to accept your answer as a correct because theres less than an hour left and I want you to get the bounty but there are some things missing, could you please edit your answer to : I have two images one above other and instead of "erasing" I'm showing the below one, and these images are loaded with `rememberAsyncImagePainter` because there are a string one and once I reach a % of percentage is possible to remove the one that I'm erasing to show only the correct one? – StuartDTO Dec 01 '22 at 16:23
  • Hey @StuartDTO, i need to check if rememberAsyncImagePainter returns a Bitmap because you definitely need Bitmap to manipulate or compare pixels . I can check out if you can get bitmap from coil painter but it's not possible with regular painter, maybe coil painter has way to expose it. You can remove Texts and Second image that is out of BoxWihtConstraints. Just add `Image` above the one that displays erased ImageBitmap to display image below that is being scratched out, this is very simple. – Thracian Dec 01 '22 at 16:44
  • So the thing is to place a new Image below the first Image, and instead of doing the gray tile is possible to paint it transparent? And the idea is to load the image with the rememberAsyncImagePainter and then try to get the bitmap? – StuartDTO Dec 02 '22 at 18:21
  • Yes, i will look into it tomorrow. You simply need to put image that should not be erased and one to be erased in same Box() { Image(notErasedImageBitmap) Image(ErasedImageBitmap)} after getting ImageBitmap or Bitmap from Coil. And remove drawBehind modifier from image i erase in above example. Text and Image outside BoxWithConstraints is only for demonstration – Thracian Dec 03 '22 at 11:29
  • There is a function in coil that would create bitmap with specified dimensions. val state = painter.state if (state is AsyncImagePainter.State.Success) { // Perform the transition animation. state.result.drawable.toBitmap() } – Thracian Dec 03 '22 at 11:33
  • Hello @Thracian may you look into this and check what I'm missing?https://paste.ofcode.org/ ZvPXBfEpZueU3ZkUJKqzty I've tried to create the two images from a string url and then pass it to the Composable but there's no image rendered... The thing is I want to instead of erasing with the gray and white tiles paint it transparent to show the image below and once the % is my desired remove the image I'm erasing – StuartDTO Dec 04 '22 at 18:15