4

Is there any way to stop this blurring when scaling a Canvas? I presume it's related to GPU interpolation. But what I need is a pixel-perfect "pixelated" zoom here. Just use the color of the nearest "real" neighboring pixel.

I've seen the solutions here but of the two suggested that work (#1 / #4), #4 is definitely CPU scaling and #1 I guess is too.

This scaling needs to be FAST - I'd like to be able to support up to maybe 20-25 layers (probably Canvases in a StackPane but I'm open to other ideas as long as they don't melt the CPU). I'm having doubts this can be done without GPU support which JFX offers, but maybe not with a flexible enough API. Strategies like #4 in the linked answer which rely on CPU rescaling probably aren't going to work.

If you zoom into the highest zoom level with this code the blurring is obvious.

Do we need an update to the JFX API to support this or something? This should be possible to do.

import javafx.application.Application;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Paint;
import javafx.stage.Stage;

public class ScaleTest extends Application {
    
    private static final int width = 1200;
    private static final int height = 800;
    private static final int topMargin = 32;
    private StackPane stackPane;
    private IntegerProperty zoomLevel = new SimpleIntegerProperty(100);
    
    @Override
    public void start(Stage stage) {
        stage.setWidth(width);
        stage.setMinHeight(height);
        
        stackPane = new StackPane();
        stackPane.setLayoutY(topMargin);
        Canvas canvas = new Canvas(width, height - topMargin);
        
        Label label = new Label();
        label.setLayoutY(2);
        label.setLayoutX(2);
        label.setStyle("-fx-text-fill: #FFFFFF");
        label.textProperty().bind(zoomLevel.asString());
        Button zoomInBtn = new Button("Zoom In");
        zoomInBtn.setLayoutY(2);
        zoomInBtn.setLayoutX(50);
        zoomInBtn.onActionProperty().set((e) -> {
            if (zoomLevel.get() < 3200) {
                zoomLevel.set(zoomLevel.get() * 2);
                stackPane.setScaleX(zoomLevel.get() / 100.0);
                stackPane.setScaleY(zoomLevel.get() / 100.0);
            }
        });
        Button zoomOutBtn = new Button("Zoom Out");
        zoomOutBtn.setLayoutY(2);
        zoomOutBtn.setLayoutX(140);
        zoomOutBtn.onActionProperty().set((e) -> {
            if (zoomLevel.get() > 25) {
                zoomLevel.set(zoomLevel.get() / 2);
                stackPane.setScaleX(zoomLevel.get() / 100.0);
                stackPane.setScaleY(zoomLevel.get() / 100.0);
            }
        });
        
        Pane mainPane = new Pane(stackPane, label, zoomInBtn, zoomOutBtn);
        mainPane.setStyle("-fx-background-color: #000000");
        Scene scene = new Scene(mainPane);
        stage.setScene(scene);
        
        drawGrid(canvas, 0, 0, width, height - topMargin, 16);
        stackPane.getChildren().add(canvas);
        
        stage.show();
    }
    
    private void drawGrid(Canvas canvas, int xPos, int yPos, int width, int height, int gridSize) {
        boolean darkX = true;
        String darkCol = "#111111";
        String lightCol = "#222266";
        
        for (int x = xPos; x < canvas.getWidth(); x += gridSize) {
            boolean dark = darkX;
            darkX = !darkX;
            if (x > width) {
                break;
            }
            
            for (int y = yPos; y < canvas.getHeight(); y += gridSize) {
                if (y > height) {
                    break;
                }
                
                dark = !dark;
                String color;
                if (dark) {
                    color = darkCol;
                } else {
                    color = lightCol;
                }
                canvas.getGraphicsContext2D().setFill(Paint.valueOf(color));
                canvas.getGraphicsContext2D().fillRect(x, y, gridSize, gridSize);
            }
        }
    }
}

blurry image

Manius
  • 3,594
  • 3
  • 34
  • 44

2 Answers2

3

Your example adjusts the node's scale properties to resample a fixed-size image, which inevitably results in such artifact. Only a vector representation can be scaled with arbitrary precision. You may need to decide how your application will support vectors and/or bitmaps. For example, if your application were really about scaling rectangles, you would invoke fillRect() with scaled coordinates, rather than scaling a picture of a smaller rectangle.

You've cited a good summary or resizing, so I'll focus on vector opportunities:

  • Concrete subclasses of Shape are, in effect, vector representations of geometric elements that can be rendered at any scale; resize the example shown here or here to see the effect; scroll to zoom and click to drag the circle here, noting that its border remains sharp at all scales.

  • An SVGPath is a Shape that can be scaled as shown here.

  • Internally, a Font stores the vector representation of individual glyphs. When instantiated, the glyph is rasterized at a certain point size.

  • If a suitable vector representation is known, drawing can be tied to the size of the Canvas as shown here.

trashgod
  • 203,806
  • 29
  • 246
  • 1,045
  • I guess I shouldn't have used a grid like that as the example since it mislead a bit. I'm able to scale via CPU fine, but obviously that doesn't 'scale up' (no pun) for large images or especially layered images. I will be doing some work with vector shapes also, but as you've implied, that's easier. What I need is basically just pixel-accurate zoom-in like a "paint program" would do. To make that easier I decided to restrict zoom levels to powers of 2 as the demo I posted does - so it should just be a matter of a clean resize of each pixel. Wouldn't that work fine for raster graphics? – Manius Dec 19 '21 at 02:54
  • Sorry, I don't know enough about your intended use to be sure; many [editors](https://en.wikipedia.org/wiki/Vector_graphics_editor#Vector_editors_versus_bitmap_editors) support both vectors and bitmaps; I've added an example that offers a different perspective on vector scaling. – trashgod Dec 19 '21 at 13:33
  • Yeah np I didn't want to bloat the question too much with detail because people might want to close it for being too specific - I don't expect mind reading. :) I'm sure some of these links will be helpful once I get to later stages, thanks. – Manius Dec 19 '21 at 17:18
1

This seems to do the trick:


package com.analogideas.scratch;
import javafx.application.Application;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Paint;
import javafx.stage.Stage;


public class ImagePixelator extends Application {

    private static final int width = 1200;
    private static final int height = 800;
    private static final int topMargin = 32;
    private IntegerProperty zoomLevel = new SimpleIntegerProperty(1);

    public static void main(String[] args) {
        launch(args);
    }
    
    @Override
    public void start(Stage stage) {
        stage.setWidth(width);
        stage.setMinHeight(height);

        Canvas canvasOut = new Canvas(width, height - topMargin);
        Canvas canvas = new Canvas(width, height - topMargin);
        drawGrid(canvas, 0, 0, width, height - topMargin, 16);
        WritableImage img = canvas.snapshot(null, null);
        drawImage(canvasOut, img, 0, 0, 1);
        StackPane pane = new StackPane(canvasOut);

        Label label = new Label();
        label.setLayoutY(2);
        label.setLayoutX(2);
        label.setStyle("-fx-text-fill: #FFFFFF");
        label.textProperty().bind(zoomLevel.asString());
        Button zoomInBtn = new Button("Zoom In");
        zoomInBtn.setLayoutY(2);
        zoomInBtn.setLayoutX(50);
        zoomInBtn.onActionProperty().set((e) -> {
            if (zoomLevel.get() < 3200) {
                zoomLevel.set(zoomLevel.get() * 2);
                int z = zoomLevel.get();
                drawImage(canvasOut, img, 0, 0, z);
            }
        });
        Button zoomOutBtn = new Button("Zoom Out");
        zoomOutBtn.setLayoutY(2);
        zoomOutBtn.setLayoutX(140);
        zoomOutBtn.onActionProperty().set((e) -> {
            if (zoomLevel.get() > 1) {
                zoomLevel.set(zoomLevel.get() /2);
                int z = zoomLevel.get();
                drawImage(canvasOut, img, 0, 0, z);
            }
        });

        Pane mainPane = new Pane(pane, label, zoomInBtn, zoomOutBtn);
        mainPane.setStyle("-fx-background-color: #000000");
        Scene scene = new Scene(mainPane);
        stage.setScene(scene);
        stage.show();
    }

    private void drawImage(Canvas canvas, Image image, int x, int y, float zoom) {
        GraphicsContext g = canvas.getGraphicsContext2D();
        g.setImageSmoothing(false);
        int w = (int) (image.getWidth() * zoom);
        int h = (int) (image.getHeight() * zoom);
        g.drawImage(image, x, y, w, h);

    }

    private void drawGrid(Canvas canvas, int xPos, int yPos, int width, int height, int gridSize) {
        Paint darkPaint = Paint.valueOf("#111111");
        Paint lightPaint = Paint.valueOf("#222266");
        GraphicsContext g = canvas.getGraphicsContext2D();
        g.setImageSmoothing(false);
        int xLimit = width / gridSize;
        int yLimit = height / gridSize;
        for (int x = 0; x <= xLimit; x++) {
            for (int y = 0; y <= yLimit; y++) {
                boolean dark = (((x ^ y) & 1) == 0);
                g.setFill(dark ? darkPaint : lightPaint);
                g.fillRect(xPos + x * gridSize, yPos + y * gridSize, gridSize, gridSize);
            }
        }
    }
}

swpalmer
  • 3,890
  • 2
  • 23
  • 31
  • Rectangles resample well without smoothing; invoke `g.fillOval()` to see the difference. – trashgod Dec 19 '21 at 02:34
  • Wasn't aware of setImageSmoothing(), that does seem to work here, but like trashgod says I wonder if it's really getting me what I need. I'm only zooming in or out by powers of 2, so... maybe? Will have to try some additional tests I guess... – Manius Dec 19 '21 at 02:45
  • Throwing in a quick fillOval() does reveal what looks like anti-aliasing when zoomed, but it looks like that's just the actual data the fillOval() routine wrote. When I take a screencap at regular size and zoom in using an image editor, it looks the same to me... so maybe sticking to zoom levels such as 100/200/400/800/1600/3200 like I've planned will be fine. – Manius Dec 19 '21 at 03:01
  • Sadly, no; rectangles resample without anti-aliasing because they are orthogonal to the rendering surface's raster—all at right angles; resize [this](https://stackoverflow.com/a/70312046/230513) to see that no aliasing appears at any stage size. – trashgod Dec 19 '21 at 03:26
  • Isn't using powers of two essentially resizing "pixel-sized" rectangles? At 200%, one pixel simply becomes 4 pixels of the same color. 400%, one pixel becomes 16 pixels of the same color, etc. – Manius Dec 19 '21 at 03:53
  • Oh no wonder I didn't know about setImageSmoothing... it was added in JFX 12 and I'm using 11! – Manius Dec 19 '21 at 04:41
  • One more reason to not stick with ancient versions of a piece of software just because it's called LTS. A little reminder: EA versions of JavaFX 18 are available already. – mipa Dec 19 '21 at 11:32
  • Generally I want to use latest versions. Unfortunately ever since 8 there has been a LOT of breaking change releases for JFX libs and some other Java libs which rely heavily on reflection, so I've been sticking to LTS for that reason. I've actually wanted to upgrade to 17 for a while, but in this case jfoenix was preventing that. After slight effort I've managed to get 16 running to try this, but yeah, will need to get to 17 at some point for obvious reasons. The new release cycle wouldn't be so bad if every other release didn't break some damn lib out there. I hope that calms down post-17. – Manius Dec 19 '21 at 17:29
  • 1
    @Manius If you are already on 11, then don't worry too much. You are past all the really hard breaking changes. I found everything after 11 to be so smooth sailing. I'm on 17 now and my large project that was designed and built around Java 8 had no issues after accommodating for Java 11's changes. – swpalmer Dec 19 '21 at 17:45
  • That's true as far as the initial modular stuff sure; now I'm finding this workaround for jfoenix has a surprise in 17: "OpenJDK 64-Bit Server VM warning: Ignoring option --illegal-access=warn; support was removed in 17.0" – Manius Dec 19 '21 at 18:01
  • @Manius Can you get around it wth ```--add-opens``` ? – swpalmer Dec 19 '21 at 18:34
  • 1
    Probably/hopefully - that's what I'm looking into now, of course there's also the Gradle upgrade necessary to get to Java 17... ! So, yeah, got a bit of work to do. :) – Manius Dec 19 '21 at 19:01
  • 1
    @Manius I also use Gradle, so if you have questions about that aspect let me know. I've bumped my project to Gradle 7.3.1. – swpalmer Dec 19 '21 at 20:11
  • 1
    Got it going, thanks. :) Also the sdkman tool I use informs me that Gradle 7.3.2 is now available! Can't keep up! – Manius Dec 19 '21 at 20:58
  • “I've actually wanted to upgrade to 17 for a while, but in this case jfoenix was preventing that” -> There have quite a few questions regarding jfoenix on this site, especially trying to get it to work with recent versions of scene builder (with varying and often poor results). I think this is an issue with jfoenix not being well maintained rather than core JavaFX, which is well maintained. Just my opinion based on the query history. – jewelsea Dec 21 '21 at 04:28
  • @jewelsea I also think the main issue is with jfoenix, but I feel like the maintainer may have threw in the towel because the amount of work required to update for such a huge library... looks intimidating. I looked into it briefly and concluded with a "hell no" - we're probably better off re-implementing whatever parts of it we need ourselves. JPMS effectively trashed that library. – Manius Apr 03 '23 at 02:05
  • 1
    @Manius See [MaterialFX](https://github.com/palexdev/MaterialFX), for which the documentation states that a “purpose is to provide a successor to the already available JFoenix library, which is a bit old and has a lot of issues”. Using MaterialFX is a better option than “re-implementing whatever parts of it we need ourselves”. – jewelsea Apr 03 '23 at 02:10
  • Ha, cool, I didn't know about it - thanks for the tip! – Manius Apr 03 '23 at 02:16