JavaFX Snapshot Scaling

If you try taking image snapshots of a JavaFX Node on a high-DPI system, you’ll find that the result is strangely blurry. This is an unfortunate side effect of the JavaFX DPI scaling introduced in Java SE 8u60. At resolutions greater than 120 DPI, JavaFX automatically treats all coordinates as abstract “layout pixels” with a resolution of 96 DPI, and later internally upscales rendering to the actual physical pixel density. That’s fundamentally a good mechanism: it ensures that you always get the best possible rendering resolution without affecting layout spacing.

This mechanism does cause trouble with snapshots, however. Snapshots transform a JavaFX Node which is usually a vector component (unlimited resolution) into a WritableImage – and that’s a bitmap component with a fixed limited resolution depending on its size. Where does that size come from? From the size of the Node whose snapshot is taken, of course – and that size is specified in layout pixels at 96 DPI. So that is the same resolution you get in your snapshot, even if your physical display density is two or four times higher. Result: blurry snapshots because the Node gets down-rendered to 96 DPI for the WritableImage.

Happily there’s a simple workaround. You don’t have to accept the default-sized image that is automatically created by Node.snapshot. You can supply your own WritableImage, with an arbitrarily upscaled size relative to the layout dimensions of the Node, and then apply a corresponding scaling transformation for the snapshot via SnapshotParameters.

Sample Program

I wrote a small sample program to demonstrate unscaled and scaled snapshots. The screenshot shows its output on my Windows 10 system with DPI scaling set to 200% = 192 DPI. The first row shows a sample Text node using Georgia. The second row shows the unacceptably blurry result of a snapshot taken with default parameters. Finally, the third row shows a snapshot scaled by a factor of four – indistinguishable from the original Text node.

The rightmost column of the top row shows the reported layout size of the original Text node. For the other two rows, this column shows the pixel dimensions of the bitmap images in the corresponding ImageView. As you can see, the unscaled snapshot directly takes its image size from the (rounded) layout size of the original Text node – hence the blurriness. The image size of the scaled ImageView equals the (rounded) layout size of the Text node multiplied by four, as expected.

Scale Snapshot

The full code of the sample program appears below. I compiled and ran it using Java SE 8u121. Snapshot scaling is implemented by the ready-to-use method createScaledView.

Note that since we upscaled the snapshot image, we have to correspondingly downscale the ImageView via setFitWidth/Height, or it would appear at four times the size of the original Text node. This is because image sizes are interpreted as 96 DPI layout pixels by JavaFX. As far as I can tell, there is no way to specify a desired DPI resolution on either WritableImage or ImageView. So to have our image appear at the correct size, we must manually adjust the size of the ImageView.

Another noteworthy point is the hard-coded scaling factor of four. This should suffice for all current displays, but it would be nicer to have it dynamically calculated based on screen DPI. Sadly, reporting physical screen DPI is another feature that JavaFX does not currently support. I believe it’s planned for an upcoming release, though.

import javafx.application.Application;
import javafx.geometry.*;
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.scene.image.*;
import javafx.scene.layout.*;
import javafx.scene.text.*;
import javafx.stage.Stage;

public class ScaleSnapshot extends Application {
    
    @Override
    public void start(Stage primaryStage) {

        final Text text = new Text("Sample Text");
        text.setFont(Font.font("Georgia", 26));
        final ImageView unscaled = new ImageView(text.snapshot(null, null));
        final ImageView scaled = createScaledView(text, 4);

        final GridPane root = new GridPane();
        root.setPadding(new Insets(8));
        root.setHgap(8); root.setVgap(8);

        root.add(new Label("Original"), 0, 0);
        root.add(text, 1, 0);
        root.add(createSizeLabel(text), 2, 0);
        
        root.add(new Label("Unscaled"), 0, 1);
        root.add(unscaled, 1, 1);
        root.add(createSizeLabel(unscaled), 2, 1);
        
        root.add(new Label("Scaled"), 0, 2);
        root.add(scaled, 1, 2);
        root.add(createSizeLabel(scaled), 2, 2);

        primaryStage.setScene(new Scene(root));
        primaryStage.setTitle("Scale Snapshot");
        primaryStage.sizeToScene();
        primaryStage.show();
    }

    private static ImageView createScaledView(Node node, int scale) {
        final Bounds bounds = node.getLayoutBounds();

        final WritableImage image = new WritableImage(
            (int) Math.round(bounds.getWidth() * scale),
            (int) Math.round(bounds.getHeight() * scale));

        final SnapshotParameters spa = new SnapshotParameters();
        spa.setTransform(javafx.scene.transform.Transform.scale(scale, scale));

        final ImageView view = new ImageView(node.snapshot(spa, image));
        view.setFitWidth(bounds.getWidth());
        view.setFitHeight(bounds.getHeight());
        
        return view;
    }
    
    private static Label createSizeLabel(Node node) {
        double width, height;

        if (node instanceof ImageView) {
            final ImageView view = (ImageView) node;
            width = view.getImage().getWidth();
            height = view.getImage().getHeight();
        } else {
            width = node.getLayoutBounds().getWidth();
            height = node.getLayoutBounds().getHeight();
        }

        return new Label(String.format("%.2f x %.2f", width, height));
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Credit: The topic for this blog post was inspired by a question from Jago Westmacott, who also supplied a sample program that formed the basis for my own.

2 thoughts on “JavaFX Snapshot Scaling”

  1. Oh man! it is wonderful. I tried many solutions but finally your post saved my day :)

  2. Thank you for this article ! I’ve been trying to write a simple FX app that takes a screenshot and then manipulates the image, but I’ve been experiencing all sorts of problems with blurriness. You blog cleared everything up ! I naively assumed that high DPI displays would “just work” but I now know that life isn’t so simple.

    I made one slight addition to your code. Since I’m cropping only a portion of the image, I used the setViewport() method to limit the size of the snapshot. Interestingly, I found that I have to scale the X/Y coordinates of the viewport, but not the width or height. I find this a little bit strange, but frankly I’m happy just to have something working !

    // Coming into this block, "rect" holds the area I want to capture in logical coordinates. // On my display, DPI_SCALING_FACTOR = 2.0 final Rectangle2D cropArea = new Rectangle2D( rect.getMinX() * Images.DPI_SCALING_FACTOR, rect.getMinY() * Images.DPI_SCALING_FACTOR, rect.getWidth(), rect.getHeight() ); final SnapshotParameters sp = new SnapshotParameters(); sp.setTransform(new Scale(Images.DPI_SCALING_FACTOR, Images.DPI_SCALING_FACTOR)); sp.setViewport(cropArea);

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.