blob: 03299dc4ef926057aa3da41442669f623f0c0b90 [file] [log] [blame]
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.idea.rendering;
import com.intellij.openapi.util.SystemInfo;
import com.intellij.util.ImageLoader;
import com.intellij.util.JBHiDPIScaledImage;
import com.intellij.util.RetinaImage;
import com.intellij.util.ui.UIUtil;
import icons.AndroidIcons;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.ImageObserver;
import static java.awt.RenderingHints.*;
/**
* Utilities related to image processing.
*/
@SuppressWarnings("UndesirableClassUsage") // BufferedImage is ok, deliberately not creating Retina images in some cases
public class ImageUtils {
public static final double EPSILON = 1e-5;
/**
* Rotates given image by given degrees which should be a multiple of 90
* @param source image to be rotated
* @param degrees the angle by which to rotate, should be a multiple of 90
* @return the rotated image
*/
public static BufferedImage rotateByRightAngle(BufferedImage source, int degrees) {
assert degrees % 90 == 0;
degrees = degrees % 360;
int w = source.getWidth();
int h = source.getHeight();
int w1, h1;
switch (degrees) {
case 90:
case 270:
w1 = h;
h1 = w;
break;
default:
w1 = w;
h1 = h;
}
BufferedImage rotated = new BufferedImage(w1, h1, source.getType());
for (int x = 0; x < w; x++) {
for (int y = 0; y < h; y++) {
int v = source.getRGB(x, y);
int x1, y1;
switch (degrees) {
case 90:
x1 = h - y - 1;
y1 = x;
break;
case 180:
x1 = w - x - 1;
y1 = h - y - 1;
break;
case 270:
x1 = y;
y1 = w - x - 1;
break;
default:
x1 = x;
y1 = y;
break;
}
rotated.setRGB(x1, y1, v);
}
}
return rotated;
}
public static boolean isRetinaImage(@Nullable BufferedImage image) {
if (image == null) {
return false;
}
return image instanceof JBHiDPIScaledImage ||
SystemInfo.isAppleJvm && UIUtil.isAppleRetina() && BufferedImage.class != image.getClass();
}
public static BufferedImage createDipImage(int width, int height, int type) {
return UIUtil.createImage(width, height, type);
}
public static boolean supportsRetina() {
return ourRetinaCapable;
}
private static boolean ourRetinaCapable = true;
@Nullable
public static BufferedImage convertToRetina(@NotNull BufferedImage image) {
final int scale = 2;
if (image.getWidth() < scale || image.getHeight() < scale) {
// Can't convert to Retina; see issue 65676
return null;
}
try {
@SuppressWarnings("ConstantConditions")
Image retina = RetinaImage.createFrom(image, scale, null);
if (!(retina instanceof BufferedImage)) {
// Don't try this again
ourRetinaCapable = false;
return null;
}
return (BufferedImage)retina;
} catch (Throwable t) {
// Can't always create Retina images (see issue 65609); fall through to non-Retina code path
ourRetinaCapable = false;
return null;
}
}
public static void drawDipImage(Graphics g, Image image,
int dx1, int dy1, int dx2, int dy2,
int sx1, int sy1, int sx2, int sy2,
@Nullable ImageObserver observer) {
if (image instanceof JBHiDPIScaledImage) {
final Graphics2D newG = (Graphics2D)g.create(0, 0, image.getWidth(observer), image.getHeight(observer));
newG.scale(0.5, 0.5);
Image img = ((JBHiDPIScaledImage)image).getDelegate();
if (img == null) {
img = image;
}
newG.drawImage(img, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, observer);
newG.scale(1, 1);
newG.dispose();
} else {
g.drawImage(image, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, observer);
}
}
/** Returns a new image that is the source image surrounded by a transparent margin of given size. */
public static BufferedImage addMargin(BufferedImage source, int marginSize) {
int destWidth = source.getWidth() + 2 * marginSize;
int destHeight = source.getHeight() + 2 * marginSize;
// since we are adding a transparent margin, make sure destination image has an alpha channel
int type = source.getColorModel().hasAlpha() ? source.getType() : BufferedImage.TYPE_INT_ARGB;
BufferedImage expanded = new BufferedImage(destWidth, destHeight, type);
Graphics2D g2 = expanded.createGraphics();
//noinspection UseJBColor
g2.setColor(new Color(0, true));
g2.fillRect(0, 0, destWidth, destHeight);
g2.drawImage(source, marginSize, marginSize, null);
g2.dispose();
return expanded;
}
/**
* Resize the given image
*
* @param source the image to be scaled
* @param xScale x scale
* @param yScale y scale
* @return the scaled image
*/
@NotNull
public static BufferedImage scale(BufferedImage source, double xScale, double yScale) {
return scale(source, xScale, yScale, 0, 0);
}
/**
* Resize the given image
*
* @param source the image to be scaled
* @param xScale x scale
* @param yScale y scale
* @param clip an optional clip rectangle to use
* @return the scaled image
*/
@NotNull
public static BufferedImage scale(BufferedImage source, double xScale, double yScale, @Nullable Shape clip) {
return scale(source, xScale, yScale, 0, 0, clip);
}
/**
* Resize the given image
*
* @param source the image to be scaled
* @param xScale x scale
* @param yScale y scale
* @param rightMargin extra margin to add on the right
* @param bottomMargin extra margin to add on the bottom
* @return the scaled image
*/
@NotNull
public static BufferedImage scale(BufferedImage source, double xScale, double yScale,
int rightMargin, int bottomMargin) {
return scale(source, xScale, yScale, rightMargin, bottomMargin, null);
}
/**
* Resize the given image
*
* @param source the image to be scaled
* @param xScale x scale
* @param yScale y scale
* @param rightMargin extra margin to add on the right
* @param bottomMargin extra margin to add on the bottom
* @param clip an optional clip rectangle to use
* @return the scaled image
*/
@NotNull
public static BufferedImage scale(BufferedImage source, double xScale, double yScale,
int rightMargin, int bottomMargin, @Nullable Shape clip) {
int sourceWidth = source.getWidth();
int sourceHeight = source.getHeight();
int destWidth = Math.max(1, (int) (xScale * sourceWidth));
int destHeight = Math.max(1, (int) (yScale * sourceHeight));
int imageType = source.getType();
if (imageType == BufferedImage.TYPE_CUSTOM) {
imageType = BufferedImage.TYPE_INT_ARGB;
}
if (xScale > 0.5 && yScale > 0.5) {
BufferedImage scaled =
new BufferedImage(destWidth + rightMargin, destHeight + bottomMargin, imageType);
Graphics2D g2 = scaled.createGraphics();
g2.setComposite(AlphaComposite.Src);
//noinspection UseJBColor
g2.setColor(new Color(0, true));
g2.fillRect(0, 0, destWidth + rightMargin, destHeight + bottomMargin);
if (clip != null) {
g2.setClip(clip);
}
if (xScale == 1 && yScale == 1) {
g2.drawImage(source, 0, 0, null);
} else {
g2.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BILINEAR);
g2.setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY);
g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
g2.drawImage(source, 0, 0, destWidth, destHeight, 0, 0, sourceWidth, sourceHeight,
null);
}
g2.dispose();
return scaled;
} else {
// When creating a thumbnail, using the above code doesn't work very well;
// you get some visible artifacts, especially for text. Instead use the
// technique of repeatedly scaling the image into half; this will cause
// proper averaging of neighboring pixels, and will typically (for the kinds
// of screen sizes used by this utility method in the layout editor) take
// about 3-4 iterations to get the result since we are logarithmically reducing
// the size. Besides, each successive pass in operating on much fewer pixels
// (a reduction of 4 in each pass).
//
// However, we may not be resizing to a size that can be reached exactly by
// successively diving in half. Therefore, once we're within a factor of 2 of
// the final size, we can do a resize to the exact target size.
// However, we can get even better results if we perform this final resize
// up front. Let's say we're going from width 1000 to a destination width of 85.
// The first approach would cause a resize from 1000 to 500 to 250 to 125, and
// then a resize from 125 to 85. That last resize can distort/blur a lot.
// Instead, we can start with the destination width, 85, and double it
// successfully until we're close to the initial size: 85, then 170,
// then 340, and finally 680. (The next one, 1360, is larger than 1000).
// So, now we *start* the thumbnail operation by resizing from width 1000 to
// width 680, which will preserve a lot of visual details such as text.
// Then we can successively resize the image in half, 680 to 340 to 170 to 85.
// We end up with the expected final size, but we've been doing an exact
// divide-in-half resizing operation at the end so there is less distortion.
int iterations = 0; // Number of halving operations to perform after the initial resize
int nearestWidth = destWidth; // Width closest to source width that = 2^x, x is integer
int nearestHeight = destHeight;
while (nearestWidth < sourceWidth / 2) {
nearestWidth *= 2;
nearestHeight *= 2;
iterations++;
}
// If we're supposed to add in margins, we need to do it in the initial resizing
// operation if we don't have any subsequent resizing operations.
if (iterations == 0) {
nearestWidth += rightMargin;
nearestHeight += bottomMargin;
}
@SuppressWarnings("UndesirableClassUsage")
BufferedImage scaled = new BufferedImage(nearestWidth, nearestHeight, imageType);
Graphics2D g2 = scaled.createGraphics();
g2.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BILINEAR);
g2.setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY);
g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
g2.drawImage(source, 0, 0, nearestWidth, nearestHeight,
0, 0, sourceWidth, sourceHeight, null);
g2.dispose();
sourceWidth = nearestWidth;
sourceHeight = nearestHeight;
source = scaled;
for (int iteration = iterations - 1; iteration >= 0; iteration--) {
int halfWidth = sourceWidth / 2;
int halfHeight = sourceHeight / 2;
if (iteration == 0) { // Last iteration: Add margins in final image
scaled = new BufferedImage(halfWidth + rightMargin, halfHeight + bottomMargin,
imageType);
g2 = scaled.createGraphics();
if (clip != null) {
g2.setClip(clip);
}
} else {
scaled = new BufferedImage(halfWidth, halfHeight, imageType);
g2 = scaled.createGraphics();
}
g2.setRenderingHint(KEY_INTERPOLATION,VALUE_INTERPOLATION_BILINEAR);
g2.setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY);
g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
g2.drawImage(source, 0, 0,
halfWidth, halfHeight, 0, 0,
sourceWidth, sourceHeight,
null);
g2.dispose();
sourceWidth = halfWidth;
sourceHeight = halfHeight;
source = scaled;
iterations--;
}
return scaled;
}
}
/**
* Crops blank pixels from the edges of the image and returns the cropped result. We
* crop off pixels that are blank (meaning they have an alpha value = 0). Note that
* this is not the same as pixels that aren't opaque (an alpha value other than 255).
*
* @param image the image to be cropped
* @param initialCrop If not null, specifies a rectangle which contains an initial
* crop to continue. This can be used to crop an image where you already
* know about margins in the image
* @return a cropped version of the source image, or null if the whole image was blank
* and cropping completely removed everything
*/
@Nullable
public static BufferedImage cropBlank(@NotNull BufferedImage image, @Nullable Rectangle initialCrop) {
return cropBlank(image, initialCrop, image.getType());
}
/**
* Crops blank pixels from the edges of the image and returns the cropped result. We
* crop off pixels that are blank (meaning they have an alpha value = 0). Note that
* this is not the same as pixels that aren't opaque (an alpha value other than 255).
*
* @param image the image to be cropped
* @param initialCrop If not null, specifies a rectangle which contains an initial
* crop to continue. This can be used to crop an image where you already
* know about margins in the image
* @param imageType the type of {@link BufferedImage} to create
* @return a cropped version of the source image, or null if the whole image was blank
* and cropping completely removed everything
*/
@Nullable
public static BufferedImage cropBlank(@Nullable BufferedImage image, @Nullable Rectangle initialCrop, int imageType) {
CropFilter filter = new CropFilter() {
@Override
public boolean crop(BufferedImage bufferedImage, int x, int y) {
int rgb = bufferedImage.getRGB(x, y);
return (rgb & 0xFF000000) == 0x00000000;
// TODO: Do a threshold of 80 instead of just 0? Might give better
// visual results -- e.g. check <= 0x80000000
}
};
return crop(image, filter, initialCrop, imageType);
}
/**
* Determines the crop bounds for the given image
*
*
* @param image the image to be cropped
* @param filter the filter determining whether a pixel is blank or not
* @param initialCrop If not null, specifies a rectangle which contains an initial
* crop to continue. This can be used to crop an image where you already
* know about margins in the image
* @return the bounds of the crop in the given image, or null if the whole image was blank
* and cropping completely removed everything
*/
public static Rectangle getCropBounds(@Nullable BufferedImage image,
@NotNull CropFilter filter,
@Nullable Rectangle initialCrop) {
if (image == null) {
return null;
}
// First, determine the dimensions of the real image within the image
int x1, y1, x2, y2;
if (initialCrop != null) {
x1 = initialCrop.x;
y1 = initialCrop.y;
x2 = initialCrop.x + initialCrop.width;
y2 = initialCrop.y + initialCrop.height;
}
else {
x1 = 0;
y1 = 0;
x2 = image.getWidth();
y2 = image.getHeight();
}
// Nothing left to crop
if (x1 == x2 || y1 == y2) {
return null;
}
// This algorithm is a bit dumb -- it just scans along the edges looking for
// a pixel that shouldn't be cropped. I could maybe try to make it smarter by
// for example doing a binary search to quickly eliminate large empty areas to
// the right and bottom -- but this is slightly tricky with components like the
// AnalogClock where I could accidentally end up finding a blank horizontal or
// vertical line somewhere in the middle of the rendering of the clock, so for now
// we do the dumb thing -- not a big deal since we tend to crop reasonably
// small images.
// First determine top edge
topEdge:
for (; y1 < y2; y1++) {
for (int x = x1; x < x2; x++) {
if (!filter.crop(image, x, y1)) {
break topEdge;
}
}
}
if (y1 == image.getHeight()) {
// The image is blank
return null;
}
// Next determine left edge
leftEdge:
for (; x1 < x2; x1++) {
for (int y = y1; y < y2; y++) {
if (!filter.crop(image, x1, y)) {
break leftEdge;
}
}
}
// Next determine right edge
rightEdge:
for (; x2 > x1; x2--) {
for (int y = y1; y < y2; y++) {
if (!filter.crop(image, x2 - 1, y)) {
break rightEdge;
}
}
}
// Finally determine bottom edge
bottomEdge:
for (; y2 > y1; y2--) {
for (int x = x1; x < x2; x++) {
if (!filter.crop(image, x, y2 - 1)) {
break bottomEdge;
}
}
}
if (x1 == x2 || y1 == y2) {
// Nothing left after crop -- blank image
return null;
}
int width = x2 - x1;
int height = y2 - y1;
return new Rectangle(x1, y1, width, height);
}
/**
* Crops a given image with the given crop filter
* @param image the image to be cropped
* @param filter the filter determining whether a pixel is blank or not
* @param initialCrop If not null, specifies a rectangle which contains an initial
* crop to continue. This can be used to crop an image where you already
* know about margins in the image
* @param imageType the type of {@link BufferedImage} to create, or -1 if unknown
* @return a cropped version of the source image, or null if the whole image was blank
* and cropping completely removed everything
*/
@Nullable
public static BufferedImage crop(@Nullable BufferedImage image, @NotNull CropFilter filter, @Nullable Rectangle initialCrop,
int imageType) {
if (image == null) {
return null;
}
Rectangle cropBounds = getCropBounds(image, filter, initialCrop);
if (cropBounds == null) {
return null;
}
int x1 = cropBounds.x;
int y1 = cropBounds.y;
int width = cropBounds.width;
int height = cropBounds.height;
int x2 = x1 + width;
int y2 = y1 + height;
// Now extract the sub-image
if (imageType == -1) {
imageType = image.getType();
}
if (imageType == BufferedImage.TYPE_CUSTOM) {
imageType = BufferedImage.TYPE_INT_ARGB;
}
BufferedImage cropped = new BufferedImage(width, height, imageType);
Graphics g = cropped.getGraphics();
g.drawImage(image, 0, 0, width, height, x1, y1, x2, y2, null);
g.dispose();
return cropped;
}
/**
* Loads a builtin icon from the Android plugin
*
* @param path the path relative to the Android plugin root; starts with /icons
*/
@NotNull
public static Image loadIcon(String path) {
Image image = ImageLoader.loadFromResource(path, AndroidIcons.class);
assert image != null : path;
return image;
}
/**
* Returns the perceived brightness of the given RGB integer on a scale from 0 to 255
*
* @param rgb the RGB triplet, 8 bits each
* @return the perceived brightness, with 0 maximally dark and 255 maximally bright
*/
public static int getBrightness(int rgb) {
if ((rgb & 0xFFFFFF) != 0) {
int r = (rgb & 0xFF0000) >> 16;
int g = (rgb & 0x00FF00) >> 8;
int b = (rgb & 0x0000FF);
// One perceived luminance formula is (0.299*red + 0.587*green + 0.114*blue)
// In order to keep this fast since we don't need a very accurate
// measure, I'll just estimate this with integer math:
return (int) ((299L*r + 587*g + 114*b) / 1000);
}
return 0;
}
/**
* Interface implemented by cropping functions that determine whether
* a pixel should be cropped or not.
*/
public interface CropFilter {
/**
* Returns true if the pixel is should be cropped.
*
* @param image the image containing the pixel in question
* @param x the x position of the pixel
* @param y the y position of the pixel
* @return true if the pixel should be cropped (for example, is blank)
*/
boolean crop(BufferedImage image, int x, int y);
}
}