4

I am designing a GUI with Java Swing and AWT (Java 8) and am struggling with the icons I use.

I load a large PNG image and scale it to 18x18px and then use it in a button or label. It works well in all resolutions when the operating system does not zoom in.

However, with the advent of large screen resolutions (hidpi), it is common practice to use operating system settings to zoom in on user interface controls, including buttons and such things in Java applications. For example, on Windows I use a 150% or 200% scaling of user elements with my 4K resolution to ensure the user interface is still usable. I imagine many users will do so as well.

When that is the case, however, the icons are merely increased in size after already scaling them down to 18x18px. That is, I first scale them down and then the operating system tries to scale them up again with the little information that is still left in the image.

Is there any way to design image icons in Java that are based on a higher resolution when the zooming/scaling capabilities of the operating system are used in order to avoid them appearing blurred?

Here is a working example:

import java.awt.Container;
import java.awt.Image;

import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JPanel;

@SuppressWarnings("serial")
class Example extends JFrame {

    public static void main(String[] args) {
        new Example();
    }
    
    public Example() {
        Container c = getContentPane();
        JPanel panel = new JPanel();
        ImageIcon icon = new ImageIcon(new ImageIcon(getClass().getResource("tabler-icon-beach.png")).getImage().getScaledInstance(18, 18, Image.SCALE_SMOOTH));
        JButton button = new JButton("Test button", icon);
        panel.add(button);
        c.add(panel);
        this.pack();
        this.setLocationRelativeTo(null);
        this.setVisible(true);
    }
}

You can find the icon here. All icons are available as PNG or SVG files.

To illustrate the problem, let me first show you two screenshots in the normal 100% screen resolution:

On Linux with 100% zoom:

100% zoom on Linux

On Windows with 100% zoom:

100% zoom on Windows

And now when I set Windows 7 to have a 200% magnification of layout elements, it's obviously just the 18x18px version stretched out, which becomes blurred:

200% zoom on Windows

Is there any way to provide a higher-resolution image icon that is used when the operating system uses a scaling that is larger than 100%? Moreover, you can see that even at 100% the image quality is not perfect; is there any way to improve that as well?

Andrew Thompson
  • 168,117
  • 40
  • 217
  • 433
Philip Leifeld
  • 2,253
  • 15
  • 24
  • *even at 100% the scaling is not perfect;* - there is no scaling done at 100%. The issue would be your image. Try using antialiasing when creating the image. *Is there any way to provide a higher-resolution image* - not what you asked, but check out: https://stackoverflow.com/questions/65742162/how-to-fix-the-blurry-image-when-i-run-my-java-swing-project/65742492#65742492 for a solution that prevents the icon from scaling. Maybe you can modify the logic to use Icons of different resolutions depending on the scaling? So maybe you use a 200% icon as the base and then scale down when required. – camickr Jan 11 '22 at 20:10
  • @camickr Thanks, I have changed "scaling" to "image quality" in the last sentence now. Thanks also for the link. If the application is scaled due to system settings, I also want the icon to be larger, in line with the rest of the GUI, just not with the bad quality it has at the lower resolution. I have already tried reading out the scaling factor and then supplied an image with a larger resolution. But it then appeared too large because it was first scaled by me and then again by the OS. Would your solution allow me to prevent that second scaling from happening? – Philip Leifeld Jan 11 '22 at 21:17
  • No the solution will always display the icon at its original size. My suggestion was that you can use the concept and create a `ScaledIcon` class. So you create your image at 200%. Then you scale the image down to the current scaling factor. The code provided shows how to get the current scaling factor. So I would create a map with a key of the scaling factor and the value the image. The painting logic would check the map to see if you have the scaled image. If not, then you create it and add it to the map. Then you use the scaled image and paint it. – camickr Jan 11 '22 at 21:26
  • For the [frame icons](https://stackoverflow.com/a/18224185/418556) you certainly can! – Andrew Thompson Jan 12 '22 at 12:18
  • @camickr I am not sure I understand how the `ScaledIcon` will do the trick. I do understand how to put different versions of the icon into a map and how to read the scaling factor of the OS. But I don't understand how it would help to tell the `JButton` or `JLabel` which one to pick. I guess it would help to know how these classes typically access the image from the `ImageIcon` object. Then one could extend that class and replace the method that does it. Would I have to replace `getImage()` to return a different version? How does the `JButton` etc then know that it shouldn't rescale it again? – Philip Leifeld Jan 12 '22 at 13:29
  • The component has nothing to do with it. In the painting method of the Icon you get the scaling factor and determine which one to pick and paint. That is why I referred you to the link above. It demonstrate how to get the scaling factor before doing the painting. – camickr Jan 12 '22 at 15:15
  • _You can see that even at 100% the image quality is not perfect; is there any way to improve that as well?_ This is because you *scale down* the original image from 240×240 to 18×18. If the original icon is in SVG format, render it to 18×18. Or use SVG icon in your app; to do so, you'll need a library that can render SVG. – Alexey Ivanov Feb 13 '22 at 15:07

2 Answers2

2

Java 8 does not support High DPI, the UI gets scaled up by Windows. You should use Java 11 or a later version which support per-monitor High DPI settings.

If your goal is to make the icons look crisp, prepare a set of icons for different resolutions using BaseMultiResolutionImage (the basic implementation of MultiResolutionImage) to provide higher resolution alternatives. (These are not available in Java 8.)

You say that you scaled down the original image (240×240) to 18×18px. If the UI needs a higher resolution according to the system setting, all it has now is your small icon (18×18) which will be scaled up, which results in poor quality. You should use a MultiResolutionImage or paint the original image into the required size, letting Graphics to scale it down for you.

No Down-Scale

This is the simplest way I came up with to make the icon 18×18 without downscaling the original image:

private static final String IMAGE_URL =
        "https://tabler-icons.io/static/tabler-icons/icons-png/beach.png";

private static ImageIcon getIcon() {
    return new ImageIcon(Toolkit.getDefaultToolkit()
                                .getImage(new URL(IMAGE_URL))) {
        @Override
        public int getIconWidth() {
            return 18;
        }
        @Override
        public int getIconHeight() {
            return 18;
        }

        @Override
        public synchronized void paintIcon(Component c, Graphics g,
                                           int x, int y) {
            g.drawImage(getImage(), x, y, 18, 18, null);
        }
    };
}

I left out the exception handling code for MalformedURLException which can be thrown from the URL constructor.

In this case, the painted image gets down-scaled each time it's painted, which is ineffective. Yet the quality is better. Well, for the standard resolution screen, it's nearly the same as if you down-scaled the image when loading. But in High DPI case, it looks better. It's because for 200% UI Scale, the image will be rendered to 36×36 pixels and these pixels will be created from the source of 240×240 rather than up-scaling the down-scaled version which lost its quality.

The screenshot of the app without down-scaling at 200%

To get even better results, I recommend using MultiResolutionImage.

MultiResolutionImage

The app below loads the images from base64-encoded strings (for simplicity so that there are no external dependencies). There are three variants provided: 24×24 (100%, 96dpi), 36×36 (150%, 144dpi), 48×48 (200%, 192dpi).

If the current scale factor is set to any of the provided resolutions, the image will be rendered as is. If 125% or 175% are used, the larger image will be scaled down; if the scale is greater than 200%, then the image for 200% will be scaled up. You can add more resolutions if needed.

The app doesn't compile in Java 8 because MultiResolutionImage is not available there. To compile it with JDK 11, you have to replace text blocks with regular String concatenation.

import java.awt.Image;
import java.awt.image.BaseMultiResolutionImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Base64;

import javax.imageio.ImageIO;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;

public class BeachIconButton {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(BeachIconButton::new);
    }

    private BeachIconButton() {
        JPanel panel = new JPanel();
        ImageIcon icon = getIcon();
        JButton button = new JButton("Test button", icon);
        panel.add(button);

        JFrame frame = new JFrame("Beach Icon Test");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.getContentPane().add(panel);
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }

    private static ImageIcon getIcon() {
        return new ImageIcon(
               new BaseMultiResolutionImage(
               Arrays.stream(new String[] { BEACH_100, BEACH_150, BEACH_200})
                     .map(BeachIconButton::loadImage)
                     .toArray(Image[]::new)));
    }

    private static Image loadImage(String base64) {
        try {
            return ImageIO.read(new ByteArrayInputStream(
                                Base64.getMimeDecoder().decode(base64)));
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }

    private static final String BEACH_100 = """
            iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAA7DAAAO
            wwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAXxJ
            REFUSInl1E9LVVEUBfAfVJBIIoWGIDiRGtagN7Yg+gTZXPwICU4FyUGDahBRCTrK
            qUVNIxrkJHjWJMKZikIjSyHoYd0GZ186vOef7n2zWnB4e591WXudt/c5/G8Ywiw+
            4gd28QGLuNCt+C18RXHI+onJuuLjIVBgGWOYyPb2syI3q4qfzZw/ir1raMXeNBay
            k+zgfNUC25nTBWxGPhfffI78XfzOVD1FP+5lrgusoweXIv+C6xE3qxYocRFrWZHv
            eBPxA5yJeK9ugRJPdU7QGPoi3+1GfFBqZIFn+BVxC88jft9NgdL9q8gH8MSfUa3V
            5BKXQ6gl9aOdey1N2GBV4VO4ik/h8CWG67rMcQ53Hf5ErOBGXfEGtjKxVSxhHi/w
            LeMe4mQV8ROZ67fSZWrHadyW7kKBqaonuC89Bcc5uyKNZ+M4wTvS9d/AY/RmXK80
            iht/wa+HVgfKB61cTYzGaups8FH89kEFhjBSUzDnR0LrSFT5Sw7i/yH8BmQ0mnmX
            f2wqAAAAAElFTkSuQmCC""";

    private static final String BEACH_150 = """
            iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAACXBIWXMAABYlAAAW
            JQFJUiTwAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAoVJ
            REFUWIXt1kuoTVEYB/DfdZWiq7gykZBHHokyw0QGREpKLgMDGSojQ6+JARFi4JFn
            ySPyyoCJR8zcIUVJiPLopiMhLoO1d3ftc+7Z95xrnYHyr1V7feu/1/ff3/etby/+
            4x9DW8K9ZmE9FmTP7fiBp3iE03iS0F9dTMQN/G5gvMLMVopZhi8NisnHD6xohZiV
            2ea5o584h+WYgAclor5jaUoxE9ATOXiOednaUMUU9mKPkK5YVCXbJwkuKkZmYbS2
            t8rxpsz+QW2krqYSdF5tCnZjCX5F9h0Zf25k66nizEghaBSOV22cRyt/vhnxd0b2
            E7gQzXelEJRjnv6L96dQ9IR+9CxaWymcsnx+L6UgQnPtUizyfDwRaiiff8JwjIts
            71MLytGBy0I9xaLitG7LuCMi2+d4kyEJBVWwWkhPfz568UaI6Mho/XtCDTXYqO/L
            v+GF2jQ+wNZofrdVYjrwNnK0XYjOYSEt9bp20lMWY0/k5LVQJzlGCn2qur56Mb0V
            YiYLKcodddXhTcX1iJesU1cj/nc9NPA9axE2Cw02GUYITe6UYgoOCv+29pTOyjAH
            VxRT1N/4iP0Y2yohHTij9v810Khgiwavy43eqSfhGmZHtt/oxm2h4fUI0Zim75IW
            4wI24GuDPutiCF4q1sklAx/XJXisGK2zfysGhum7N3/F2ibebce+SNCdFIJgMQ4J
            F63BYBUOYEqzL87HLaEeKrivPCLrMk5lEPyezNf8MkHv9H9STioegDbF/vM3/Hdl
            grpLnBxFJ8bgWAmvWX53maBOId/jMRpHGnB8JOM2yx+f+eosE1SNNiH89TY/oTY1
            zfAHjS7hMv45G3exJiH/P/5d/AHE21JDZYKOHAAAAABJRU5ErkJggg==""";

    private static final String BEACH_200 = """
            iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAB2HAAAd
            hwGP5fFlAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAA5FJ
            REFUaIHt2ctrXFUcB/BPYoWGVGOwBZtYQQoqhUp9IFEoulC04spGExQEpYoiVBBc
            qVAVwYX/gEWqooKboItqN4oV0frWPgR3XRSl9mHT2GjaJh0X5w6ZOXPuzO08roL5
            wlnM+T3O93cev3vOb1jCEv7f6CtpnBswjg1Yh2HM4wx+wh58jC9K4lMIfXgIB1Ap
            2H7FC7jwX+Bbh2uwW3HiqUDWlE26io040YJgkXYKd5TM3Z34K0HmNKYwgavxgLD/
            WwUxi1vKIr8GfyRIfIAravTuxtmE3g+Jvkrm86pek+/XuOfP4elIbz1mIr15bBUm
            IG8lPtHjjLkpMeg+jNboDOKXSOcsJjP5bU0CqODBsgOoHsRnsRyvJ+RP1vh4PpId
            in7v7WUA/diRE0QFhxN92yMfeyL5MxoP+nW9DAJuxY85QdS2AxiosVsrnJmqfAGX
            4/3I7uVeBwAX4DEcaRLAFFbV2LwSyXdn/ZNR/86es6/BCmyTTpnVWX4Tl2pMv49k
            PtZF/QfLIl+L9fhN/mrEK3XE4vYajGQzZRKPcTuOyw+k2rbV2FwSyY7GTvt7yTjC
            UQwV0FspEKf+nMBcVxmdJz7VeEVYkF6F43gKD0f9H5bOOsN9Gkluws04lpBV21z0
            +7myiRO+wgcjInE6HE/opNpYOZTrEV8RzghX6hjLhWvHKWny35RBNsZogtCrLWxG
            8JrG8zHRO5r5eCci8bvFDNMKY/hMeNRsF77wpWJM/f2mgke7PciyLvtbKby4NuJe
            9Y+QGdyEv7FLSJX/CfThfqGmM691Jqlkep8L6bWs2lQSd+F7xUjnte+EYkCpGMQb
            HRKP247M73mhneVbhY9wY0J2TpjRnUKV4bBQIxrGalyPe4RSY+oe9q1who61wasQ
            +vC1xtlbwFu4sqCftXhb+i70lR6ei+HEgPtxbZv+NuDnhM/hjpnmoE+YoepAu7Sx
            byOsELZkWyvQzlINCTWaGbwnpMROsUx4/16Md3GyE2cDQqXsS0wLRPfhJfWFqjyM
            Zrr78WcX7KczLlvVVzJyMSU/1U1brKSlMJnp9Mp+qkgAs00cVL+imxN244p9iTux
            ny0SQLMVqLY5PCHs2SGhRHi6gF2n9oVWYABbhOLUZbgoGyx+3rUi+Hhm26n96ozL
            FgXPQB4262yLdGrfFUxo/vfRCc1fTp3adwUjeFEod5/M2l7h38WREuyXsIQl1OAf
            9zFZ1uiy3BkAAAAASUVORK5CYII=""";
}
Alexey Ivanov
  • 11,541
  • 4
  • 39
  • 68
  • Interesting. Can you provide an amended version of the minimal example from the question on this basis? – Philip Leifeld Feb 14 '22 at 16:05
  • @PhilipLeifeld Do you have the link to the SVG version of the beach icon? – Alexey Ivanov Feb 14 '22 at 17:15
  • There you go: https://tabler-icons.io/static/tabler-icons/icons/beach.svg – Philip Leifeld Feb 14 '22 at 21:34
  • @PhilipLeifeld I added the sample code to the answer. Did you have a chance to try it? – Alexey Ivanov Feb 17 '22 at 09:30
  • Sorry, I wasn't notified of the answer for some reason and have seen it just now. Thanks a lot. I'll give it a try until tomorrow and will report back. For clarification: Did you scale the PNG in a separate graphics program, open it in a text editor, and copy the contents, or how did you get those long strings? I assume I can just put the PNGs in the package and load them as images instead? – Philip Leifeld Feb 17 '22 at 14:03
  • I used an online SVG to PNG converter and used 96, 144 and 192 dpi. It rendered to 24×24 pixels. There was an option to specify the size in pixels. Then I converted the PNG files into base64 encoding using `openssl base64 -in `. Yes, keep them as images in PNG and load them. I used the the base64-strings to make the sample self-contained, without external dependencies. – Alexey Ivanov Feb 17 '22 at 15:35
  • 1
    I've been looking at this in some more detail. Good stuff. But it sounds like it will be a lot of clutter and work if I have to save 8 or so different PNGs or base64 strings for each icon in my application. I saw [here](https://stackoverflow.com/questions/32721467/) that there is an SVG Transcoder that can create PNGs in arbitrary sizes ad hoc. Perhaps that's something to plug into your solution. I'll experiment with that and mark your answer as the accepted answer for now. I also came across [Radiance](https://github.com/kirill-grouchnikov/radiance), which apparently uses an SVG Transcoder. – Philip Leifeld Feb 18 '22 at 15:02
  • There's no other way to support High DPI: you need multiple images. Alternatively, keep the icons in SVG and render SVG directly, it's probably the best way yet it requires an external library to render SVG. – Alexey Ivanov Feb 18 '22 at 15:19
1

I have meanwhile figured out how to take SVG files and transcode them into BaseMultiResolutionImage objects for different scaling levels and turn the result into an image icon for use in a JButton:

import java.awt.image.BaseMultiResolutionImage;
import java.awt.image.BufferedImage;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;

import org.apache.batik.transcoder.TranscoderException;
import org.apache.batik.transcoder.TranscoderInput;
import org.apache.batik.transcoder.TranscoderOutput;
import org.apache.batik.transcoder.image.ImageTranscoder;

import javax.swing.*;

public class SvgIcon {
    private BaseMultiResolutionImage b;

    // constructor: accepts a path to an SVG file and the baseline dimensions
    public SvgIcon(String svgPath, int baselineSize) {
        this.b = this.createMultiImage(svgPath, baselineSize);
    }

    // return an image icon with the multi-resolution image
    public ImageIcon getImageIcon() {
        return new ImageIcon(this.b);
    }

    // create a multi-resolution image for nine common scaling levels
    private BaseMultiResolutionImage createMultiImage(String svgPath, int baselineSize) {
        double[] zoomFactors = {1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0};
        List<BufferedImage> images = new ArrayList<>();
        for (double zoomFactor : zoomFactors) {
            int size = (int) (baselineSize * zoomFactor);
            BufferedImage image = transcodeSvgToImage(svgPath, size, size);
            images.add(image);
        }
        return new BaseMultiResolutionImage(images.stream().toArray(BufferedImage[]::new));
    }

    // wrapper for the transcoding
    private BufferedImage transcodeSvgToImage(String svgPath, int width, int height) {
        String inputString = null;
        try {
            inputString = getClass().getResource(svgPath).toURI().toString();
        } catch (URISyntaxException e) {
            // deal with the exception
        }
        BufferedImageTranscoder transcoder = new BufferedImageTranscoder();
        transcoder.addTranscodingHint(ImageTranscoder.KEY_WIDTH, (float) width);
        transcoder.addTranscodingHint(ImageTranscoder.KEY_HEIGHT, (float) height);
        TranscoderInput input = new TranscoderInput(inputString);
        try {
            transcoder.transcode(input, null);
        } catch (TranscoderException e) {
            // deal with the exception
        }
        return transcoder.getImage();
    }

    private static class BufferedImageTranscoder extends ImageTranscoder {
        private BufferedImage image = null;

        public BufferedImage getImage() {
            return image;
        }

        @Override
        public BufferedImage createImage(int w, int h) {
            BufferedImage bi = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
            return bi;
        }

        @Override
        public void writeImage(BufferedImage img, TranscoderOutput output) {
            image = img;
        }
    }
}

Note that the SVG files are stored under icons/ in my JAR file and I declared them as a resource in my Gradle setup and IDE. If you are reading them from disk, replace the getClass().getResource(svgPath).toURI().toString() part above by a String URL to the SVG file.

With this class, I can create buttons with image icons for different scaling/zoom levels as follows.

ImageIcon imageIcon = new SvgIcon("/icons/tabler-icon-beach.svg", 18).getImageIcon();
JButton button = new JButton(imageIcon);

The argument 18 is the baseline size as requested in the original post. The SVG is scaled and stored with various sizes at increments of 25% over the baseline size up to three times as large as the baseline size. So, if the scaling factor of my display is 2 (i.e., 200%), the transcoded version with 36px is used.

This solution requires the Apache Batik Transcoder 1.16. I include it in Gradle like this:

// https://mvnrepository.com/artifact/org.apache.xmlgraphics/batik-transcoder
implementation group: 'org.apache.xmlgraphics', name: 'batik-transcoder', version: '1.16'

Note that BaseMultiResolutionImage is only available in Java 11 and higher. I asked for Java 8 in the question above, but ultimately found it worthwhile to upgrade the requirement to 11 for this functionality.

Philip Leifeld
  • 2,253
  • 15
  • 24
  • You can make your `MultiResolutionImage` even smarter: instead of storing nine icons at different resolutions all the time, you can rasterise the SVG image only for the requested resolution. This will save some memory, yet it will take more time to display the appropriate image size when the scale (DPI) of the display changes. – Alexey Ivanov Aug 26 '23 at 17:44