JavaFX: Perspective Transformation

As stated in one of my previous articles, the PerspectiveTransformation is good for transforming the visual repensentation of a node/shape, but its original position still stays the same. This means that if I want an transformed area to react on events (like mouse clicks), I have to transform the coordinates and then define the position.

As all I need is the location of a shape, a polygon in general, to be transformed, this can be done by trans-locating each point. All transformations can be described by a matrix. As this is a 2D operation this will be a 4×3 matrix multiplied with the location vector, augmented by a 1:

\begin{bmatrix} \vec{y} \\ 1 \end{bmatrix} = \begin{bmatrix} A & \vec{b} \ \\ 0, \ldots, 0 & 1 \end{bmatrix} \begin{bmatrix} \vec{x} \\ 1 \end{bmatrix}

Here x is the original position and y is the trans-located position. This can also be written as:

\vec{y} = A \vec{x} + \vec{b}

The transformation matrix of a polygon is defined by the transformation of the bounding box of the polygon. The general case where the four corners of the bounding box are translated can be reduced to the special case where only three corners are translated. Therefore this transformation algorithm will take the source and destination points in 2D space of the three corners that are moved.

This is the result:

JavaFX_Polygon_Perspective_Transformation

To achieve this three components are needed: A property class defining the Transition from one Point2D to another, the Transformation class which will take three transformation points and caluculates the matrix based on that input, and finally the test program.

package ch.sahits.javafx.test.transform;

import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Point2D;

public class Translation2D {
    private final ReadOnlyObjectProperty<Point2D> source;
    private final ReadOnlyObjectProperty<Point2D> destination;

    public Translation2D(Point2D source, Point2D destination) {
        this.source = new SimpleObjectProperty<>(this, "source", source);
        this.destination = new SimpleObjectProperty<>(this, "destination", destination);
    }

    public Translation2D(double srcX, double srcY, double destX, double destY) {
        this(new Point2D(srcX, srcY), new Point2D(destX, destY));
    }

    public Point2D getSource() {
        return source.getValue();
    }

    public Point2D getDestination() {
        return destination.getValue();
    }

    public ReadOnlyObjectProperty<Point2D> sourceProperty() {
        return source;
    }

    public ReadOnlyObjectProperty<Point2D> destinationProperty() {
        return destination;
    }    
}

Nothing relay exciting here, but we will relay on this class for in the transformation:

package ch.sahits.javafx.test.transform;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.geometry.Point2D;
import javafx.scene.shape.Polygon;

public class PolygonPerspectiveTransformation {

    private final static Translation2D ORIGIN_ORIGIN = new Translation2D(0, 0, 0, 0);

    private final ObjectProperty<Translation2D> point1 = new SimpleObjectProperty<>(this, "point-1",ORIGIN_ORIGIN);
    private final ObjectProperty<Translation2D> point2 = new SimpleObjectProperty<>(this, "point-2", ORIGIN_ORIGIN);
    private final ObjectProperty<Translation2D> point3 = new SimpleObjectProperty<>(this, "point-3", ORIGIN_ORIGIN);

    /* Initialize the Matrix with the identity */
    private double a11 = 1;
    private double a12 = 0;
    private double a21 = 0;
    private double a22 = 1;
    private double b1 = 0;
    private double b2 = 0;

    public PolygonPerspectiveTransformation() {
        point1.addListener(new RecalculationEventListener());
        point2.addListener(new RecalculationEventListener());
        point3.addListener(new RecalculationEventListener());
   }
    /**
     * Recalculate the matrix values based on the points.
     */
    private void calculate() {
        a12 = calculateA12();
        a11 = calculateA11(); // requires recalculated a12
        b1 = calculateB1(); // requires recalculated a11 and a12
        a22 = calculateA22();
        a21 = calculateA21(); // requires recalculated a22
        b2 = calculateB2(); // requires recalculated a21 and a22
    }
    private double calculateA12() {
        double y1 = point1.getValue().getSource().getY();
        double y2 = point2.getValue().getSource().getY();
        double y3 = point3.getValue().getSource().getY();
        double x1 = point1.getValue().getSource().getX();
        double x2 = point2.getValue().getSource().getX();
        double x3 = point3.getValue().getSource().getX();
        double x1d = point1.getValue().getDestination().getX();
        double x2d = point2.getValue().getDestination().getX();
        double x3d = point3.getValue().getDestination().getX();

        double diff_x2_x1 = x2 - x1;
        double diff_x3_x1 = x3 - x1;

        double top = x3d - x1d + ((x1d - x2d)*diff_x3_x1/diff_x2_x1);
        double bottom = y3 - y1 - ((y2 - y1)*diff_x3_x1/diff_x2_x1);

        return top / bottom;
    }
    private double calculateA11() {
        double x1 = point1.getValue().getSource().getX();
        double x2 = point2.getValue().getSource().getX();
        double y1 = point1.getValue().getSource().getY();
        double y2 = point2.getValue().getSource().getY();
        double x1d = point1.getValue().getDestination().getX();
        double x2d = point2.getValue().getDestination().getX();

        double top = x2d - x1d - a12*(y2 - y1);
        double bottom = x2 - x1;

        return top / bottom;
   }
    private double calculateB1() {
        double x1 = point1.getValue().getSource().getX();
        double y1 = point1.getValue().getSource().getY();
        double x1d = point1.getValue().getDestination().getX();

        return x1d - a11*x1 - a12*y1;
   }

    private double calculateA22() {
        double y1 = point1.getValue().getSource().getY();
        double y2 = point2.getValue().getSource().getY();
        double y3 = point3.getValue().getSource().getY();
        double x1 = point1.getValue().getSource().getX();
        double x2 = point2.getValue().getSource().getX();
        double x3 = point3.getValue().getSource().getX();
        double y1d = point1.getValue().getDestination().getY();
        double y2d = point2.getValue().getDestination().getY();
        double y3d = point3.getValue().getDestination().getY();

        double diff_x2_x1 = x2 - x1;
        double diff_x3_x1 = x3 - x1;

        double top = y3d - y1d - ((y2d - y1d)*diff_x3_x1/diff_x2_x1);
        double bottom = y3 - y1 - ((y2 - y1)*diff_x3_x1/diff_x2_x1);

        return top / bottom;
    }
    private double calculateA21() {
        double y1 = point1.getValue().getSource().getY();
        double y2 = point2.getValue().getSource().getY();
        double x1 = point1.getValue().getSource().getX();
        double x2 = point2.getValue().getSource().getX();
        double y1d = point1.getValue().getDestination().getY();
        double y2d = point2.getValue().getDestination().getY();

        double top = y2d - y1d - a22*(y2 - y1);
        double bottom = x2 - x1;

        return top / bottom;
    }

    private double calculateB2() {
        double x1 = point1.getValue().getSource().getX();
        double y1 = point1.getValue().getSource().getY();
        double y1d = point1.getValue().getDestination().getY();

        return y1d - a21*x1 - a22*y1;
   }
    /**
     * Transform a polygon using the defined perspective transformation
     * @param polygon
     * @return 
     */
    public Polygon transform(Polygon polygon) {
        List<Point2D> points = convertToPoints(polygon.getPoints());
        Polygon transformedPolygon = new Polygon();
        for (Point2D p : points) {
            double xd = p.getX()*a11 + p.getY()*a12 + b1;
            double yd = p.getX()*a21 + p.getY()*a22 + b2;
            transformedPolygon.getPoints().addAll(xd, yd);
        }
        return transformedPolygon;
    }

    private List<Point2D> convertToPoints(ObservableList<Double> points) {
        ArrayList<Point2D> list = new ArrayList<>();
        for (Iterator<Double> it = points.iterator(); it.hasNext();) {
            double x = it.next();
            double y = it.next();
            list.add(new Point2D(x, y));
        }
        return list;
    }

    public Translation2D getPoint3() {
        return point3.get();
    }

    public void setPoint3(Translation2D value) {
        point3.set(value);
    }

    public ObjectProperty<Translation2D> point3Property() {
        return point3;
    }

    public Translation2D getPoint2() {
        return point2.get();
    }

    public void setPoint2(Translation2D value) {
        point2.set(value);
    }

    public ObjectProperty<Translation2D> point2Property() {
        return point2;
    }

    public Translation2D getPoint1() {
        return point1.get();
    }

    public void setPoint1(Translation2D value) {
        point1.set(value);
    }

    public ObjectProperty<Translation2D> point1Property() {
        return point1;
    }

    /**
     * Ensure that the values are recalculated on property change.
     */
    private class RecalculationEventListener implements ChangeListener<Translation2D> {
        @Override
        public void changed(ObservableValue<? extends Translation2D> ov, Translation2D t, Translation2D t1) {
            calculate();
        }
    }

}

This is the class that does the heavy lifting. The main work is to recalculate the matrix every time the Translation2D properties change. The derivation of the calculation is trivial but lengthy. The starting point are these four formulas:
x1‚ = x1a11 + y1a12 + b1
y1‚ = x1a21 + y1a22 + b2
x2‚ = x2a11 + y2a12 + b1
y2‚ = x2a21 + y2a22 + b2
x3‚ = x3a11 + y3a12 + b1
y3‚ = x3a21 + y3a22 + b2

The x‘, y‘ variables are the destination and in the code represented by x1d, y1d, …

The final piece is the application itself:

package ch.sahits.javafx.test.stretch;

import ch.sahits.javafx.test.ResizeableCanvas;
import ch.sahits.javafx.test.transform.PolygonPerspectiveTransformation;
import ch.sahits.javafx.test.transform.Translation2D;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.application.Application;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.effect.PerspectiveTransform;
import javafx.scene.effect.PerspectiveTransformBuilder;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.RectangleBuilder;
import javafx.stage.Stage;

public class PerspectivePolygonTransformation extends Application {

    @Override
    public void start(Stage primaryStage) {
        try {
            Group root = new Group();
            Image img = new Image(getClass().getResource("PaperScroll.png").openStream());
            final double width = img.getWidth();
            final double height = img.getHeight();
            Scene scene = new Scene(root, width, height);
            ImageView imageView = new ImageView(img);

            Polygon untranformed = new Polygon(370,50,500,30,520,100,330,90);
            untranformed.setFill(Color.RED);
            untranformed.setOpacity(0.5);
            untranformed.setOnMouseClicked(new EventHandler<MouseEvent>(){

                @Override
                public void handle(MouseEvent t) {
                    System.out.println("Clicked on untransformed polygon");
                }
            });

            PolygonPerspectiveTransformation transformer = new PolygonPerspectiveTransformation();
            transformer.setPoint1(new Translation2D(366, 29, 366+200, 29)); // top left
            transformer.setPoint2(new Translation2D(366+559, 29, 366+544, 29+19)); // top right
            transformer.setPoint3(new Translation2D(366, 29+502, 366, 29+403)); // bottom right

            Polygon transformed = transformer.transform(untranformed);
            transformed.setFill(Color.BLUE);
            transformed.setOpacity(0.5);
            transformed.setOnMouseClicked(new EventHandler<MouseEvent>(){

                @Override
                public void handle(MouseEvent t) {
                    System.out.println("Clicked on transformed polygon");
                }
            });

            root.getChildren().addAll(imageView, untranformed, transformed);     
            primaryStage.setTitle("Perspective Polygon Transform");
            primaryStage.setScene(scene);
            primaryStage.show();
        } catch (IOException ex) {
            Logger.getLogger(ResizeableCanvas.class.getName()).log(Level.SEVERE, null, ex);
        }
    }

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

As this transformation does not operate on a node and we push all nodes in the root node, the coordinates relative to the root node have to be specified in Translation2D parameters.

Schreibe einen Kommentar