Android ImageView.setMatrix() and .invalidate() - repainting takes too much time
Asked Answered
E

3

15

Task: I want to resize and move an image across the screen. I want to do this smoothly no matter how big the image is. The code should be supported by the API level 8.

Problem: I tried to use ImageView with scaleType="matrix". Calling ImageView.setMatrix() and then ImageView.invalidate() works great with small images but horrible with big ones. No matter how big the ImageView is.

Can I somehow speed up repainting of the ImageView so it will not recalculate whole image? Maybe there is a way to accomplish the task using different component?


EDIT: More information on what I am trying to achieve.

  • pw, ph - width and height of the picture (in pixels)
  • dw, dh - width and height of the device's display (in pixels)
  • fw, fh - width and height of the visible frame (in pixels)
  • x, y - position of top left corner of the frame (in pixels) Visualisation of the problem.

I want to display a part of the image on the screen. Properties x, y, fw and fh are changing constantly. I am looking for a part of code (idea), or components which for these 8 specified variables will quickly generate and display the part of the image.


EDIT 2: Info on pw and ph

I assume pw and ph can hold values from 1 to infinity. If this approach causes a lot of trouble we can assume the picture is not bigger than the picture taken with the device's camera.

Edme answered 4/10, 2015 at 19:55 Comment(7)
The point of ImageView is to display an image from a resource, usually a compressed source like JPEG or PNG. It's not really intended for fast smooth animation. Try Canvas with a Bitmap or OpenGL ES with a texture. Bear in mind that the uncompressed size of the image will be relevant to performance.Frogfish
If it's just about resizing and moving, the animation APIs should be enough.Pentastyle
So you want to display a sub-section of a very large image? I take it fw/fh are proportional to dh/dw, but not fixed, so you're both panning and zooming? How large are pw/ph, i.e. is holding the full image in memory unreasonable (especially on an API 8 era device)?Frogfish
@Frogfish Correct. For info on pw and ph see my edited question.Edme
The most general solution would be to split the image into a series of tiles. The images for the visible tiles are loaded, the rest are ignored. When scrolling you need only load the tiles at the border you're moving toward. Essentially the same as Google Maps or a side-scrolling video game.Frogfish
@Frogfish Inspired by your comment I think have a solution. 1. Load the image with BitmapFactory, so that the result bitmap's width is not greater than the display's width and the result bitmap's height is not greater than the display's height. 2. Everytime when x, y, fw or fh changes start (the same) asynchronous process that will generate a bitmap of selected area. If the process is working already restart it with other parameters. 3. If the process happen to finish display the result to user, covering the first bitmap. 3b Clear generated bitmap when x, y, fw, fh changes.Edme
@Yvette Why put a limit? If the image is too big it will cause an exception which will be caught and the user will be informed that he tried to load too big image. If you want to say the question is too broad this way I stated in the question that you can assume the image is not bigger than the image taken by the device camera.Edme
E
2

With your help (community) I figured out the solution. I am sure that there are other better ways to do it but my solution is not very complicated and should work with any image, any Android since API level 8.

The solution is to use two ImageView objects instead of one.

The first ImageView will be working like before but loaded image will be scaled down so that it's width will be smaller than the width of the ImageView and it's height will be smaller than the height of the ImageView.

The second ImageView will be blank at the start. Everytime the x, y, fw and fh properties are changing the AsyncTask will be executed to load only visible part of the image. When properties are changing fast the AsyncTask will not be able to finish in time. It will have to be canceled and new one will be started. When it finishes the result Bitmap will be loaded onto the second ImageView so it will be visible to user. When the properties changes again loaded Bitmap will be deleted, so it will not cover moving Bitmap loaded to the first ImageView. Note: BitmapRegionDecoder which I will use to load sub-image is available since Android API level 10, so API 8 and API 9 users will only see scaled down image. I decided it is OK.


Code needed:

  • Setting the first (bottom) ImageView scaleType="matrix" (best in XML)
  • Setting the second (top) ImageView scaleType="fitXY" (best in XML)
  • Functions from Android Documentation (here) - thanks to user Vishavjeet Singh.

NOTE: Notice the || operator instead of && while calculating inSampleSize. We want the image loaded to be smaller than ImageView so that we are sure we have enough RAM to load it. (I presume ImageView size is not bigger than the size of the device display. I also presume that the device has enough memory to load at least 2 Bitmaps of the size of the device display. Please tell me if I am making a mistake here.)
NOTE 2: I am loading images using InputStream. To load a file different way you will have to change code in try{...} catch(...){...} blocks.

public static int calculateInSampleSize(
        BitmapFactory.Options options, int reqWidth, int reqHeight) {
    // Raw height and width of image
    final int height = options.outHeight;
    final int width = options.outWidth;
    int inSampleSize = 1;

    if (height > reqHeight || width > reqWidth) {

        final int halfHeight = height / 2;
        final int halfWidth = width / 2;

        // Calculate the largest inSampleSize value that is a power of 2 and keeps both
        // height and width larger than the requested height and width.
        while ((halfHeight / inSampleSize) > reqHeight
                || (halfWidth / inSampleSize) > reqWidth) {
            inSampleSize *= 2;
        }
    }

    return inSampleSize;
}

public Bitmap decodeSampledBitmapFromResource(Uri fileUri,
                                              int reqWidth, int reqHeight) {

    // First decode with inJustDecodeBounds=true to check dimensions
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;

    try {
        InputStream is = this.getContentResolver().openInputStream(fileUri);
        BitmapFactory.decodeStream(is, null, options);
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }

    // Calculate inSampleSize
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

    // Decode bitmap with inSampleSize set
    options.inJustDecodeBounds = false;

    try {
        InputStream is = this.getContentResolver().openInputStream(fileUri);
        return BitmapFactory.decodeStream(is, null, options);
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
}
  • Function returning a sub-image of an image.

NOTE: Size of Rectangle that will be cut out of source image is relative to the image. Values that specify it are from 0 to 1 because the size of the ImageView and loaded Bitmaps differs from the size of the original image.

public Bitmap getCroppedBitmap (Uri fileUri, int outWidth, int outHeight,
                                    double rl, double rt, double rr, double rb) {
        // rl, rt, rr, rb are relative (values from 0 to 1) to the size of the image.
        // That is because image moving will be smaller than the original.
        if (Build.VERSION.SDK_INT >= 10) {
            // Ensure that device supports at least API level 10
            // so we can use BitmapRegionDecoder
            BitmapRegionDecoder brd;
            try {
                // Again loading from URI. Change the code so it suits yours.
                InputStream is = this.getContentResolver().openInputStream(fileUri);
                brd = BitmapRegionDecoder.newInstance(is, true);

                BitmapFactory.Options options = new BitmapFactory.Options();
                options.outWidth = (int)((rr - rl) * brd.getWidth());
                options.outHeight = (int)((rb - rt) * brd.getHeight());
                options.inSampleSize = calculateInSampleSize(options,
                        outWidth, outHeight);

                return brd.decodeRegion(new Rect(
                        (int) (rl * brd.getWidth()),
                        (int) (rt * brd.getHeight()),
                        (int) (rr * brd.getWidth()),
                        (int) (rb * brd.getHeight())
                ), options);
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
        }
        else
            return null;
    }
  • AsyncTask loading the sub-image Bitmap.

NOTE: notice declaring a variable of the type of this class. It will be used later.

private LoadHiResImageTask loadHiResImageTask = new LoadHiResImageTask();

private class LoadHiResImageTask extends AsyncTask<Double, Void, Bitmap> {
        /** The system calls this to perform work in a worker thread and
         * delivers it the parameters given to AsyncTask.execute() */
        protected Bitmap doInBackground(Double... numbers) {
            return getCroppedBitmap(
                    // You will have to change first parameter here!
                    Uri.parse(imagesToCrop[0]),
                    numbers[0].intValue(), numbers[1].intValue(),
                    numbers[2], numbers[3], numbers[4], numbers[5]);
        }

        /** The system calls this to perform work in the UI thread and delivers
         * the result from doInBackground() */
        protected void onPostExecute(Bitmap result) {
            ImageView hiresImage = (ImageView) findViewById(R.id.hiresImage);
            hiresImage.setImageBitmap(result);
            hiresImage.postInvalidate();
        }
    }
  • Function that will make it all work together.

This function will be called every time the x, y, fw or fh property changes.
NOTE: hiresImage in my code is the id of the second (top) ImageView

private void updateImageView () {
        //  ... your code to update ImageView matrix ...
        // 
        // imageToCrop.setImageMatrix(m);
        // imageToCrop.postInvalidateDelayed(10);

        if (Build.VERSION.SDK_INT >= 10) {
            ImageView hiresImage = (ImageView) findViewById(R.id.hiresImage);
            hiresImage.setImageDrawable(null);
            hiresImage.invalidate();
            if (loadHiResImageTask.getStatus() != AsyncTask.Status.FINISHED) {
                loadHiResImageTask.cancel(true);
            }
            loadHiResImageTask = null;
            loadHiResImageTask = new LoadHiResImageTask();
            loadHiResImageTask.execute(
                    (double) hiresImage.getWidth(),
                    (double) hiresImage.getHeight(),
                    // x, y, fw, fh are properties from the question
                    (double) x / d.getIntrinsicWidth(),
                    (double) y / d.getIntrinsicHeight(),
                    (double) x / d.getIntrinsicWidth()
                            + fw / d.getIntrinsicWidth(),
                    (double) y / d.getIntrinsicHeight()
                            + fh / d.getIntrinsicHeight());
        }
    }
Edme answered 7/11, 2015 at 9:1 Comment(0)
B
1

Try loading source bitmap by BitmapFactory Options in just decode bounds

public static int calculateInSampleSize(
            BitmapFactory.Options options, int reqWidth, int reqHeight) {
    // Raw height and width of image
    final int height = options.outHeight;
    final int width = options.outWidth;
    int inSampleSize = 1;

    if (height > reqHeight || width > reqWidth) {

        final int halfHeight = height / 2;
        final int halfWidth = width / 2;

        // Calculate the largest inSampleSize value that is a power of 2 and keeps both
        // height and width larger than the requested height and width.
        while ((halfHeight / inSampleSize) > reqHeight
                && (halfWidth / inSampleSize) > reqWidth) {
            inSampleSize *= 2;
        }
    }

    return inSampleSize;
}

public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
        int reqWidth, int reqHeight) {

    // First decode with inJustDecodeBounds=true to check dimensions
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(res, resId, options);

    // Calculate inSampleSize
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

    // Decode bitmap with inSampleSize set
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(res, resId, options);
}

After writing these 2 methods

mImageView.setImageBitmap(
    decodeSampledBitmapFromResource(getResources(), R.id.myimage, 100, 100));

set bitmap on imageview like this

then try scaling the image it will do for a large bitmap efficiently

You can read here further

http://developer.android.com/training/displaying-bitmaps/load-bitmap.html

Burundi answered 3/11, 2015 at 17:9 Comment(5)
The problem with this approach is it becomes pointless to zoom in the image. Image is scaled down so zooming won't reveal any more details. However I think BitmapFactory may be solution to my problem. I will add more details to question soon.Edme
Keeping insamplesize=2 wouldn't do any harm to image quality. I have tested this.Burundi
While a user is zooming it will cause the image to be reloaded. If the picture is very big it will be eventually loaded to big to process. If from specified size I will not allow the image to be reloaded bigger we would have situation from the first comment.Edme
how does zooming cause image to reload?Burundi
Please look at my edited question. I need a sub-section of an Image. Code in your answer creates a bitmap of whole Image not bigger than given width and height. That is not a solution to my problem. To answer you question: zooming does not cause relaoding, that is true. However if I want the user to see more detail in a zoomed image I need to reload it.Edme
S
1

As you already said you could crop your big image and place it one by one to the new imageView.

But from the other side I could suggest you to cut your big image into several small ones and in this case you probably save memory and speed because you won't load the whole image into your memory.

You will gain similar that for example google maps have - they have many small tiles that are loaded by demand. They don't load the whole world map but the small parts of it.

In this case you will build something like ListView when each new item will be an imageview and contains some small image that is part of a big image. By the way with such approach you could get even repeated tiled background and change them on runtime.

Schonthal answered 8/11, 2015 at 10:31 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.