001/*
002 *    GeoAPI - Java interfaces for OGC/ISO standards
003 *    Copyright © 2011-2024 Open Geospatial Consortium, Inc.
004 *    http://www.geoapi.org
005 *
006 *    Licensed under the Apache License, Version 2.0 (the "License");
007 *    you may not use this file except in compliance with the License.
008 *    You may obtain a copy of the License at
009 *
010 *        http://www.apache.org/licenses/LICENSE-2.0
011 *
012 *    Unless required by applicable law or agreed to in writing, software
013 *    distributed under the License is distributed on an "AS IS" BASIS,
014 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 *    See the License for the specific language governing permissions and
016 *    limitations under the License.
017 */
018package org.opengis.test.referencing;
019
020import java.util.Arrays;
021import java.util.Random;
022import java.awt.geom.AffineTransform;
023
024import org.opengis.util.FactoryException;
025import org.opengis.referencing.operation.Matrix;
026import org.opengis.referencing.operation.MathTransform;
027import org.opengis.referencing.operation.TransformException;
028import org.opengis.referencing.operation.MathTransformFactory;
029import org.opengis.test.Configuration;
030
031import org.junit.jupiter.api.Test;
032
033import static java.lang.StrictMath.*;
034import static org.junit.jupiter.api.Assertions.*;
035import static org.junit.jupiter.api.Assumptions.assumeTrue;
036import static org.opengis.test.referencing.PseudoEpsgFactory.FEET;
037
038
039/**
040 * Tests {@linkplain MathTransformFactory#createAffineTransform(Matrix) affine transforms}
041 * from the {@code org.opengis.referencing.operation} package. Math transform instances are
042 * created using the factory given at construction time.
043 *
044 * <h2>Usage example:</h2>
045 * in order to specify their factories and run the tests in a JUnit framework, implementers can
046 * define a subclass in their own test suite as in the example below:
047 *
048 * {@snippet lang="java" :
049 * import org.opengis.test.referencing.AffineTransformTest;
050 *
051 * public class MyTest extends AffineTransformTest {
052 *     public MyTest() {
053 *         super(new MyMathTransformFactory());
054 *     }
055 * }}
056 *
057 * @see ParameterizedTransformTest
058 *
059 * @author  Martin Desruisseaux (Geomatys)
060 * @version 3.1
061 * @since   3.1
062 */
063@SuppressWarnings("strictfp")   // Because we still target Java 11.
064public strictfp class AffineTransformTest extends TransformTestCase {
065    /**
066     * The message when a test is disabled because no transform factory has been found.
067     */
068    static final String NO_FACTORY = "No math transform factory found.";
069
070    /**
071     * The message when a test is disabled because the implementation supports only two-dimensional spaces.
072     *
073     * @see #isNonBidimensionalSpaceSupported
074     */
075    private static final String TWO_DIMENSIONAL_ONLY = "Only two-dimensional spaces are supported by the tested implementation.";
076
077    /**
078     * The message when a test is disabled because the implementation supports only square matrices.
079     *
080     * @see #isNonSquareMatrixSupported
081     */
082    private static final String SQUARE_MATRIX_ONLY = "Only square matrices are supported by the tested implementation.";
083
084    /**
085     * The default tolerance threshold for comparing the results of direct transforms.
086     * Because affine transform are linear, only rounding errors should exist.
087     */
088    private static final double TRANSFORM_TOLERANCE = 1E-8;
089
090    /**
091     * The delta value to use for computing an approximation of the derivative by finite
092     * difference, in metres. Because affine transform are linear, this value should
093     * actually have no impact on the result.
094     */
095    private static final double DERIVATIVE_DELTA = 1;
096
097    /**
098     * Approximate number of points to transform in each test.
099     */
100    private static final int NUM_POINTS = 2500;
101
102    /**
103     * The factory for creating {@link MathTransform} objects, or {@code null} if none.
104     */
105    protected final MathTransformFactory mtFactory;
106
107    /**
108     * The matrix of the math transform being tested. This field is set, together with the
109     * {@link #transform transform} field, after the execution of every {@code testFoo()}
110     * method in this class.
111     *
112     * <p>If this field is non-null before a test is run, then those parameters will be used
113     * directly. This allow implementers to alter the parameters before to run the test one
114     * more time.</p>
115     */
116    protected Matrix matrix;
117
118    /**
119     * {@code true} if {@link MathTransformFactory#createAffineTransform(Matrix)} accepts
120     * non-square matrixes. Transforms defined by non-square matrixes have a number of
121     * input dimensions different than the number of output dimensions.
122     */
123    protected boolean isNonSquareMatrixSupported;
124
125    /**
126     * {@code true} if {@link MathTransformFactory#createAffineTransform(Matrix)} accepts
127     * matrixes of size different than 3×3. If {@code false}, then only matrixes of
128     * size 3×3 (i.e. affine transforms between two-dimensional spaces) will be tested.
129     */
130    protected boolean isNonBidimensionalSpaceSupported;
131
132    /**
133     * Creates a new test using the given factory. If the given factory is {@code null},
134     * then the tests will be skipped.
135     *
136     * @param factory  factory for creating {@link MathTransform} instances.
137     */
138    @SuppressWarnings("this-escape")
139    public AffineTransformTest(final MathTransformFactory factory) {
140        mtFactory = factory;
141        final boolean[] isEnabled = getEnabledFlags(
142                Configuration.Key.isNonSquareMatrixSupported,
143                Configuration.Key.isNonBidimensionalSpaceSupported);
144        isNonSquareMatrixSupported       = isEnabled[0];
145        isNonBidimensionalSpaceSupported = isEnabled[1];
146    }
147
148    /**
149     * Returns information about the configuration of the test which has been run.
150     * This method returns a map containing:
151     *
152     * <ul>
153     *   <li>All the entries defined in the {@linkplain TransformTestCase#configuration() parent class}.</li>
154     *   <li>All the following values associated to the {@link org.opengis.test.Configuration.Key} of the same name:
155     *     <ul>
156     *       <li>{@link #isNonSquareMatrixSupported}</li>
157     *       <li>{@link #isNonBidimensionalSpaceSupported}</li>
158     *       <li>{@link #mtFactory}</li>
159     *     </ul>
160     *   </li>
161     * </ul>
162     *
163     * @return {@inheritDoc}
164     */
165    @Override
166    public Configuration configuration() {
167        final Configuration op = super.configuration();
168        assertNull(op.put(Configuration.Key.isNonSquareMatrixSupported,       isNonSquareMatrixSupported));
169        assertNull(op.put(Configuration.Key.isNonBidimensionalSpaceSupported, isNonBidimensionalSpaceSupported));
170        assertNull(op.put(Configuration.Key.mtFactory,                        mtFactory));
171        return op;
172    }
173
174    /**
175     * Runs a test using the given Java2D affine transform as a reference.
176     *
177     * @param  reference  the affine transform to use as a reference for checking the results.
178     * @throws FactoryException if the transform cannot be created.
179     * @throws TransformException if an error occurred while testing the transform.
180     */
181    private void runTest(final AffineTransform reference) throws FactoryException, TransformException {
182        assumeTrue(mtFactory != null, NO_FACTORY);
183        if (matrix == null) {
184            matrix = new SimpleMatrix(3, 3,
185                    reference.getScaleX(), reference.getShearX(), reference.getTranslateX(),
186                    reference.getShearY(), reference.getScaleY(), reference.getTranslateY(),
187                                 0,              0,                  1);
188        }
189        if (transform == null) {
190            transform = mtFactory.createAffineTransform(matrix);
191            assertNotNull(transform);
192        }
193        final float[] coordinates = verifyInternalConsistency(reference.hashCode());
194        /*
195         * At this point, we have performed internal consistency check of the
196         * implementer transform. Now compute the expected values using the
197         * Java2D transform and compare with the implementer transform.
198         */
199        final double[] source = new double[coordinates.length];
200        final double[] target = new double[coordinates.length];
201        for (int i=0; i<coordinates.length; i++) {
202            source[i] = coordinates[i];
203        }
204        reference.transform(source, 0, target, 0, coordinates.length/2);
205        verifyTransform(source, target);
206        for (int i=0; i<coordinates.length; i++) {
207            assertEquals(coordinates[i], source[i], "Source array should be unmodified.");
208        }
209    }
210
211    /**
212     * Runs a test using the given matrix. This method checks only for internal consistency,
213     * i.e. we don't have an external implementation that we can use for comparing the results.
214     *
215     * @param  numRow    number of rows in the matrix.
216     * @param  numCol    number of columns in the matrix.
217     * @param  elements  matrix element values.
218     * @throws FactoryException if the transform cannot be created.
219     * @throws TransformException if an error occurred while testing the transform.
220     */
221    private void runTest(final int numRow, final int numCol, final double... elements)
222            throws FactoryException, TransformException
223    {
224        assumeTrue(mtFactory != null, NO_FACTORY);
225        if (matrix == null) {
226            matrix = new SimpleMatrix(numRow, numCol, elements);
227        }
228        if (transform == null) {
229            transform = mtFactory.createAffineTransform(matrix);
230            assertNotNull(transform);
231        }
232        verifyInternalConsistency(Arrays.hashCode(elements));
233    }
234
235    /**
236     * Tests the transform consistency using many random points inside an arbitrary domain.
237     *
238     * @param  seed  the random seed. We recommend a constant value for each transform or CRS to be tested.
239     * @return the generated random coordinates inside the arbitrary domain.
240     * @throws TransformException if a point cannot be transformed.
241     */
242    private float[] verifyInternalConsistency(final long seed) throws TransformException {
243        validators.validate(transform);
244        if (!(tolerance >= TRANSFORM_TOLERANCE)) {      // !(a >= b) instead of (a < b) in order to catch NaN.
245            tolerance = TRANSFORM_TOLERANCE;
246        }
247        final int dimension = transform.getSourceDimensions();
248        final int[]    num = new int   [dimension];
249        final double[] min = new double[dimension];
250        final double[] max = new double[dimension];
251        derivativeDeltas   = new double[dimension];
252        Arrays.fill(num, (int) ceil(pow(NUM_POINTS, 1.0/num.length)));
253        Arrays.fill(min, -1000);
254        Arrays.fill(max, +1000);
255        Arrays.fill(derivativeDeltas, DERIVATIVE_DELTA);
256        return verifyInDomain(min, max, num, new Random(seed));
257    }
258
259    /**
260     * Tests using an identity transform in an one-dimensional space.
261     * This test is executed only if the {@link #isNonBidimensionalSpaceSupported}
262     * flag is set to {@code true}.
263     *
264     * @throws FactoryException should never happen.
265     * @throws TransformException should never happen.
266     */
267    @Test
268    public void testIdentity1D() throws FactoryException, TransformException {
269        assumeTrue(isNonBidimensionalSpaceSupported, TWO_DIMENSIONAL_ONLY);
270        configurationTip = Configuration.Key.isNonBidimensionalSpaceSupported;
271        runTest(2, 2,
272            1, 0,
273            0, 1);
274        assertTrue(transform.isIdentity(), "MathTransform.isIdentity()");
275    }
276
277    /**
278     * Tests using an identity transform in a two-dimensional space.
279     *
280     * @throws FactoryException should never happen.
281     * @throws TransformException should never happen.
282     */
283    @Test
284    public void testIdentity2D() throws FactoryException, TransformException {
285        runTest(new AffineTransform());
286        assertTrue(transform.isIdentity(), "MathTransform.isIdentity()");
287    }
288
289    /**
290     * Tests using an identity transform in a three-dimensional space.
291     * This test is executed only if the {@link #isNonBidimensionalSpaceSupported}
292     * flag is set to {@code true}.
293     *
294     * @throws FactoryException should never happen.
295     * @throws TransformException should never happen.
296     */
297    @Test
298    public void testIdentity3D() throws FactoryException, TransformException {
299        assumeTrue(isNonBidimensionalSpaceSupported, TWO_DIMENSIONAL_ONLY);
300        configurationTip = Configuration.Key.isNonBidimensionalSpaceSupported;
301        runTest(4, 4,
302            1, 0, 0, 0,
303            0, 1, 0, 0,
304            0, 0, 1, 0,
305            0, 0, 0, 1);
306        assertTrue(transform.isIdentity(), "MathTransform.isIdentity()");
307    }
308
309    /**
310     * Tests a transform swapping the axes in a two-dimensional space.
311     *
312     * @throws FactoryException should never happen.
313     * @throws TransformException should never happen.
314     */
315    @Test
316    public void testAxisSwapping2D() throws FactoryException, TransformException {
317        runTest(new AffineTransform(0, 1, 1, 0, 0, 0));
318        assertFalse(transform.isIdentity(), "MathTransform.isIdentity()");
319    }
320
321    /**
322     * Tests using a 180° rotation in a two-dimensional space.
323     *
324     * @throws FactoryException should never happen.
325     * @throws TransformException should never happen.
326     */
327    @Test
328    public void testSouthOrientated2D() throws FactoryException, TransformException {
329        runTest(AffineTransform.getQuadrantRotateInstance(2));
330        assertFalse(transform.isIdentity(), "MathTransform.isIdentity()");
331    }
332
333    /**
334     * Tests using a translation of (400000,-100000) metres in a two-dimensional space.
335     * This translation is determined by the <q>False easting</q> and <q>False northing</q>
336     * parameter values of the <cite>OSGB 1936 / British National Grid</cite> projection.
337     *
338     * @throws FactoryException should never happen.
339     * @throws TransformException should never happen.
340     */
341    @Test
342    public void testTranslatation2D() throws FactoryException, TransformException {
343        runTest(AffineTransform.getTranslateInstance(400000, -100000));
344        assertFalse(transform.isIdentity(), "MathTransform.isIdentity()");
345    }
346
347    /**
348     * Tests using a uniform scale factor of 0.3048 in a two-dimensional space.
349     * This is the conversion factor from <i>feet</i> to <i>metres</i>.
350     *
351     * @throws FactoryException should never happen.
352     * @throws TransformException should never happen.
353     */
354    @Test
355    public void testUniformScale2D() throws FactoryException, TransformException {
356        runTest(AffineTransform.getScaleInstance(FEET, FEET));
357        assertFalse(transform.isIdentity(), "MathTransform.isIdentity()");
358    }
359
360    /**
361     * Tests using a non-uniform scale factor of (3,4) in a two-dimensional space.
362     *
363     * @throws FactoryException should never happen.
364     * @throws TransformException should never happen.
365     */
366    @Test
367    public void testGenericScale2D() throws FactoryException, TransformException {
368        runTest(AffineTransform.getScaleInstance(3, 4));
369        assertFalse(transform.isIdentity(), "MathTransform.isIdentity()");
370    }
371
372    /**
373     * Tests using an anticlockwise 30° rotation in a two-dimensional space.
374     *
375     * @throws FactoryException should never happen.
376     * @throws TransformException should never happen.
377     */
378    @Test
379    public void testRotation2D() throws FactoryException, TransformException {
380        runTest(AffineTransform.getRotateInstance(toRadians(30)));
381        assertFalse(transform.isIdentity(), "MathTransform.isIdentity()");
382    }
383
384    /**
385     * Tests using a combination of scale, rotation and translation in a two-dimensional space.
386     *
387     * @throws FactoryException should never happen.
388     * @throws TransformException should never happen.
389     */
390    @Test
391    public void testGeneral() throws FactoryException, TransformException {
392        final AffineTransform reference = AffineTransform.getTranslateInstance(10,-20);
393        reference.rotate(0.5);
394        reference.scale(0.2, 0.3);
395        reference.translate(300, 500);
396        runTest(reference);
397        assertFalse(transform.isIdentity(), "MathTransform.isIdentity()");
398    }
399
400    /**
401     * Tests a transform which reduce the number of dimensions from 4 to 2.
402     * This test is executed only if the {@link #isNonSquareMatrixSupported}
403     * flag is set to {@code true}.
404     *
405     * @throws FactoryException should never happen.
406     * @throws TransformException should never happen.
407     */
408    @Test
409    public void testDimensionReduction() throws FactoryException, TransformException {
410        assumeTrue(isNonSquareMatrixSupported, SQUARE_MATRIX_ONLY);
411        configurationTip = Configuration.Key.isNonSquareMatrixSupported;
412        final int sourceDim = 4;
413        final int targetDim = 2;
414        final boolean inverseSupported = isInverseTransformSupported;
415        isInverseTransformSupported = false;
416        try {
417            runTest(targetDim+1, sourceDim+1,
418                2,  0,  0,  0,  8,
419                0,  0,  4,  0,  5,
420                0,  0,  0,  0,  1);
421            /*
422             * Tests hard-coded known points.
423             */
424            final double[] source = new double[] {0,0,0,0 , 1,1,1,1 , 8,3,-7,5};
425            final double[] target = new double[] {8,5 ,     10,9 ,    24,-23};
426            verifyTransform(source, target);
427            /*
428             * Inverse the transform (this is the interesting part of this test) and try again.
429             * The coordinates at index 1 and 3 (indices of columns were all elements are 0 in
430             * the above matrix) are expected to be NaN.
431             */
432            if (inverseSupported) {
433                configurationTip = Configuration.Key.isInverseTransformSupported;
434                for (int i=0; i<source.length; i += sourceDim) {
435                    source[i + 1] = Double.NaN;
436                    source[i + 3] = Double.NaN;
437                }
438                final MathTransform direct = transform;
439                transform = direct.inverse();
440                try {
441                    verifyTransform(target, source);
442                } finally {
443                    transform = direct;
444                }
445            }
446        } finally {
447            isInverseTransformSupported = inverseSupported;
448        }
449        assertFalse(transform.isIdentity(), "MathTransform.isIdentity()");
450    }
451}