You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2022/08/12 16:13:33 UTC

[sis] branch geoapi-4.0 updated: Add an `IsolineViewer` widget for watching isoline generation step-by-step. For debugging purposes only.

This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 357e4d946b Add an `IsolineViewer` widget for watching isoline generation step-by-step. For debugging purposes only.
357e4d946b is described below

commit 357e4d946b145a7361a748b28deb8686c647aed9
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Fri Aug 12 18:12:29 2022 +0200

    Add an `IsolineViewer` widget for watching isoline generation step-by-step.
    For debugging purposes only.
---
 .../internal/processing/image/IsolineTracer.java   |  45 ++-
 .../sis/internal/processing/image/Isolines.java    |  40 ++-
 .../internal/processing/image/package-info.java    |   2 +-
 .../internal/processing/image/IsolineViewer.java   | 333 +++++++++++++++++++++
 4 files changed, 416 insertions(+), 4 deletions(-)

diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/IsolineTracer.java b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/IsolineTracer.java
index 63dcb38366..b0e55cadd0 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/IsolineTracer.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/IsolineTracer.java
@@ -25,11 +25,13 @@ import java.util.Collections;
 import java.awt.Point;
 import java.awt.Rectangle;
 import java.awt.Shape;
+import java.awt.geom.Path2D;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.internal.feature.j2d.PathBuilder;
 import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.util.ArraysExt;
+import org.apache.sis.util.Debug;
 
 
 /**
@@ -39,7 +41,7 @@ import org.apache.sis.util.ArraysExt;
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.3
  *
  * @see <a href="https://en.wikipedia.org/wiki/Marching_squares">Marching squares on Wikipedia</a>
  *
@@ -71,11 +73,13 @@ final class IsolineTracer {
 
     /**
      * Pixel coordinate on the left side of the cell where to interpolate.
+     * The range is 0 inclusive to {@code domain.width} exclusive.
      */
     int x;
 
     /**
      * Pixel coordinate on the top side of the cell where to interpolate.
+     * The range is 0 inclusive to {@code domain.height} exclusive.
      */
     int y;
 
@@ -676,6 +680,25 @@ final class IsolineTracer {
                 path  = null;
             }
         }
+
+        /**
+         * Appends the pixel coordinates of this level to the given path, for debugging purposes only.
+         * The {@link #gridToCRS} transform is <em>not</em> applied by this method.
+         * For avoiding confusing behavior, that transform should be null.
+         *
+         * @param  appendTo  where to append the coordinates.
+         *
+         * @see Isolines#toRawPath()
+         */
+        @Debug
+        final void toRawPath(final Path2D appendTo) {
+            final Shape s = (path != null) ? path.build() : shape;
+            if (s != null) appendTo.append(s, false);
+            polylineOnLeft.toRawPath(appendTo);
+            for (final Polyline p : polylinesOnTop) {
+                if (p != null) p.toRawPath(appendTo);
+            }
+        }
     }
 
     /**
@@ -842,6 +865,26 @@ final class IsolineTracer {
         public String toString() {
             return PathBuilder.toString(coordinates, size);
         }
+
+        /**
+         * Appends the pixel coordinates of this polyline to the given path, for debugging purposes only.
+         * The {@link #gridToCRS} transform is <em>not</em> applied by this method.
+         * For avoiding confusing behavior, that transform should be null.
+         *
+         * @param  appendTo  where to append the coordinates.
+         *
+         * @see Level#toRawPath(Path2D)
+         */
+        @Debug
+        private void toRawPath(final Path2D appendTo) {
+            int i = 0;
+            if (i < size) {
+                appendTo.moveTo(coordinates[i++], coordinates[i++]);
+                while (i < size) {
+                    appendTo.lineTo(coordinates[i++], coordinates[i++]);
+                }
+            }
+        }
     }
 
     /**
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/Isolines.java b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/Isolines.java
index abc87cd60b..a3532ecb56 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/Isolines.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/Isolines.java
@@ -21,6 +21,7 @@ import java.util.Arrays;
 import java.util.List;
 import java.util.TreeMap;
 import java.util.NavigableMap;
+import java.util.function.BiConsumer;
 import java.util.concurrent.Future;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.CompletionException;
@@ -34,6 +35,7 @@ import org.apache.sis.image.PixelIterator;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.Debug;
 
 import static org.apache.sis.internal.processing.image.IsolineTracer.UPPER_LEFT;
 import static org.apache.sis.internal.processing.image.IsolineTracer.UPPER_RIGHT;
@@ -59,6 +61,15 @@ public final class Isolines {
      */
     private final IsolineTracer.Level[] levels;
 
+    /**
+     * A consumer to notify about the current state of isoline generation, or {@code null} if none.
+     * This is used for debugging purposes only. This field is temporarily set to a non-null value
+     * when using {@code IsolineViewer} (in test package) for following an isoline generation step
+     * by step.
+     */
+    @Debug
+    private static final BiConsumer<String,Path2D> LISTENER = null;
+
     /**
      * Creates an initially empty set of isolines for the given levels. The given {@code values}
      * array should be one of the arrays validated by {@link #cloneAndSort(double[][])}.
@@ -377,13 +388,19 @@ abort:  while (iterator.next()) {
                 }
             }
             /*
-             * Finished iteration on a row. Clear flags and update position
-             * before to move to next row.
+             * Finished iteration on a row. Clear flags and update position before to move to next row.
+             * If there is listeners to notify (for debugging purposes), notify them.
              */
             for (int b=0; b<numBands; b++) {
                 for (final IsolineTracer.Level level : isolines[b].levels) {
                     level.finishedRow();
                 }
+                if (LISTENER != null) {
+                    final int y = tracer.y;
+                    final int h = iterator.getDomain().height;
+                    LISTENER.accept(String.format("After row %d of %d (%3.1f%%)", y, h, 100f*y/h),
+                                    isolines[b].toRawPath());
+                }
             }
             tracer.x = 0;
             tracer.y++;
@@ -395,6 +412,9 @@ abort:  while (iterator.next()) {
             for (final IsolineTracer.Level level : isolines[b].levels) {
                 level.finish();
             }
+            if (LISTENER != null) {
+                LISTENER.accept("Finished band " + b, isolines[b].toRawPath());
+            }
         }
         return isolines;
     }
@@ -503,4 +523,20 @@ abort:  while (iterator.next()) {
             return isolines().clone();
         }
     }
+
+    /**
+     * Appends the pixel coordinates of all level to the given path, for debugging purposes only.
+     * The {@link #gridToCRS} transform is <em>not</em> applied by this method.
+     * For avoiding confusing behavior, that transform should be null.
+     *
+     * @param  appendTo  where to append the coordinates.
+     */
+    @Debug
+    private Path2D toRawPath() {
+        final Path2D path = new Path2D.Float();
+        for (final IsolineTracer.Level level : levels) {
+            level.toRawPath(path);
+        }
+        return path;
+    }
 }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/package-info.java b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/package-info.java
index a0873312f0..6dd6be1b79 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/package-info.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/image/package-info.java
@@ -25,7 +25,7 @@
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.3
  * @since   1.1
  * @module
  */
diff --git a/core/sis-feature/src/test/java/org/apache/sis/internal/processing/image/IsolineViewer.java b/core/sis-feature/src/test/java/org/apache/sis/internal/processing/image/IsolineViewer.java
new file mode 100644
index 0000000000..446946b93c
--- /dev/null
+++ b/core/sis-feature/src/test/java/org/apache/sis/internal/processing/image/IsolineViewer.java
@@ -0,0 +1,333 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.internal.processing.image;
+
+import java.awt.Shape;
+import java.awt.Color;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.BasicStroke;
+import java.awt.EventQueue;
+import java.awt.BorderLayout;
+import java.awt.Container;
+import java.awt.Rectangle;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.geom.Path2D;
+import java.awt.geom.PathIterator;
+import java.awt.image.RenderedImage;
+import javax.swing.Timer;
+import javax.swing.JFrame;
+import javax.swing.JPanel;
+import javax.swing.JLabel;
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.ButtonModel;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+import java.util.function.BiConsumer;
+import java.util.concurrent.CountDownLatch;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.internal.referencing.j2d.AffineTransform2D;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * A viewer for showing isoline generation step-by-step.
+ * For enabling the use of this class, temporarily remove {@code private} and {@code final} keywords in
+ * {@link Isolines#LISTENER}, then uncomment the {@link #setListener(IsolineViewer)} constructor body.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.3
+ * @since   1.3
+ * @module
+ */
+@SuppressWarnings("serial")
+public final class IsolineViewer extends JComponent implements BiConsumer<String,Path2D>, ChangeListener, ActionListener {
+    /**
+     * Sets the component to be notified after each row of isolines generated from the rendered image.
+     * The body of this method is commented-out because {@link Isolines#LISTENER} is private and final.
+     * The body should be uncommented only temporarily during debugging phases.
+     */
+    private static void setListener(final IsolineViewer listener) {
+        // Isolines.LISTENER = listener;
+    }
+
+    /**
+     * Entry point for debugging. Edit this method body as needed for loading an image to use as test data.
+     *
+     * @param  args  ignored.
+     * @throws Exception if an error occurred during I/O or isoline generation.
+     */
+    public static void main(final String[] args) throws Exception {
+        // showStepByStep(local.test.DebugIsoline.data(), 0);
+    }
+
+    /**
+     * Size of the window and spacing between borders and isolines. All values are in pixels.
+     */
+    private static final int CANVAS_WIDTH = 1600, CANVAS_HEIGHT = 1000, PADDING = 3;
+
+    /**
+     * Whether to flip X and/or Y axis.
+     */
+    private static final boolean FLIP_X = false, FLIP_Y = true;
+
+    /**
+     * Description of current step. This title is updated at each isoline generation step,
+     * when {@link #accept(String, Shape)} is invoked.
+     */
+    private final JLabel stepTitle;
+
+    /**
+     * The button for moving to the next step. When this button is enabled, the isoline process is blocked
+     * by {@link #blocker} until this button is pressed. When this button is pressed, the isoline process
+     * continue until {@link #accept(String, Shape)} is invoked again.
+     *
+     * @see #actionPerformed(ActionEvent)
+     */
+    private final JButton next;
+
+    /**
+     * Simulate a "next" action after some delay. This is used when users keep the "Next" button pressed.
+     */
+    private final Timer delayedNext;
+
+    /**
+     * Blocks the isoline computation thread until the user is ready to see the next step.
+     */
+    private CountDownLatch blocker;
+
+    /**
+     * The isolines to show.
+     */
+    private Path2D isolines;
+
+    /**
+     * Bounds of {@link #isolines}, slightly expanded for making easier to see.
+     */
+    private Rectangle bounds;
+
+    /**
+     * Conversion from pixel indices in the source image to pixel indices in the displayed window.
+     */
+    private final AffineTransform2D sourceToCanvas;
+
+    /**
+     * Creates a new viewer.
+     *
+     * @param  data  the source of data for isolines.
+     * @param  pane  the container where to add components.
+     */
+    @SuppressWarnings("ThisEscapedInObjectConstruction")
+    private IsolineViewer(final RenderedImage data, final Container pane) {
+        final double scaleX = (CANVAS_WIDTH  - 2*PADDING) / (double) data.getWidth();
+        final double scaleY = (CANVAS_HEIGHT - 2*PADDING) / (double) data.getHeight();
+        sourceToCanvas = new AffineTransform2D(
+                FLIP_X ? -scaleX : scaleX, 0, 0, FLIP_Y ? -scaleY : scaleY,
+                scaleX * (PADDING + data.getMinX() + (FLIP_X ? data.getWidth()  : 0)),
+                scaleY * (PADDING + data.getMinY() + (FLIP_Y ? data.getHeight() : 0)));
+
+        stepTitle = new JLabel();
+        next = new JButton("Next");
+        next.setEnabled(false);
+        next.addActionListener(this);
+        next.getModel().addChangeListener(this);
+        delayedNext = new Timer(1000, this::fastForward);       // 1 second delay before fast forward.
+        delayedNext.setRepeats(false);
+
+        final JPanel bar = new JPanel(new BorderLayout());
+        bar .add(stepTitle, BorderLayout.CENTER);
+        bar .add(next,      BorderLayout.EAST);
+        pane.add(bar,       BorderLayout.NORTH);
+        pane.add(this,      BorderLayout.CENTER);
+    }
+
+    /**
+     * Generates isolines for the given image and show the result step by step.
+     * The given image shall have only one band.
+     *
+     * @param  data    the source of data for isolines.
+     * @param  levels  levels of isolones to generate.
+     */
+    public static void showStepByStep(final RenderedImage data, final double... levels) {
+        assertEquals("Unsupported number of bands.", 1, data.getSampleModel().getNumBands());
+        final JFrame frame = new JFrame("Step-by-step isoline viewer");
+        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+        frame.setLayout(new BorderLayout());
+        final IsolineViewer viewer = new IsolineViewer(data, frame.getContentPane());
+        final Isolines iso;
+        try {
+            setListener(viewer);
+            frame.setVisible(true);
+            frame.setSize(CANVAS_WIDTH, CANVAS_HEIGHT);
+            iso = Isolines.generate(data, new double[][] {levels}, null)[0];
+        } catch (TransformException e) {
+            throw new AssertionError(e);        // Should not happen because we specified an identity transform.
+        } finally {
+            setListener(null);
+        }
+        final Path2D path = new Path2D.Float();
+        for (final Shape shape : iso.polylines().values()) {
+            path.append(shape, false);
+        }
+        viewer.accept("Final result", path);
+    }
+
+    /**
+     * Invoked when the isolines need to be drawn.
+     */
+    @Override
+    protected void paintComponent(final Graphics g) {
+        super.paintComponent(g);
+        final Graphics2D gh = (Graphics2D) g;
+        if (bounds != null) {
+            gh.setStroke(new BasicStroke(2));
+            gh.setColor(Color.RED);
+            gh.draw(bounds);
+        }
+        if (isolines != null) {
+            gh.setStroke(new BasicStroke(1));
+            gh.setColor(Color.BLUE);
+            gh.draw(isolines);
+        }
+    }
+
+    /**
+     * Returns {@code true} if the shapes described by given iterators are equal.
+     * This is used for deciding if it is worth to bother the user with a request
+     * for pressing the "Next" button.
+     */
+    private static boolean equal(final PathIterator it1, final PathIterator it2) {
+        final float[] a1 = new float[6];
+        final float[] a2 = new float[6];
+        while (!it1.isDone()) {
+            if (it2.isDone()) return false;
+            final int code = it1.currentSegment(a1);
+            if (code != it2.currentSegment(a2)) {
+                return false;
+            }
+            int n;
+            switch (code) {
+                case PathIterator.SEG_MOVETO:
+                case PathIterator.SEG_LINETO:  n = 2; break;
+                case PathIterator.SEG_QUADTO:  n = 4; break;
+                case PathIterator.SEG_CUBICTO: n = 6; break;
+                case PathIterator.SEG_CLOSE:   n = 0; break;
+                default: throw new AssertionError(code);
+            }
+            while (--n >= 0) {
+                if (Float.floatToIntBits(a1[n]) != Float.floatToIntBits(a2[n])) {
+                    return false;
+                }
+            }
+            it1.next();
+            it2.next();
+        }
+        return it2.isDone();
+    }
+
+    /**
+     * Invoked after a row has been processed during the isoline generation.
+     * This is invoked from the main thread (<strong>not</strong> the Swing thread).
+     *
+     * @param  title   description of current state.
+     * @param  update  new isolines to show.
+     */
+    @Override
+    public void accept(final String title, final Path2D update) {
+        update.transform(sourceToCanvas);
+        final Rectangle b = update.getBounds();
+        b.x      -= PADDING;
+        b.y      -= PADDING;
+        b.width  += PADDING * 2;
+        b.height += PADDING * 2;
+        try {
+            final CountDownLatch c = new CountDownLatch(1);
+            EventQueue.invokeLater(() -> {
+                if (isolines != null && equal(isolines.getPathIterator(null), update.getPathIterator(null))) {
+                    stepTitle.setText(title + " (no change)");
+                    c.countDown();
+                } else {
+                    stepTitle.setText(title);
+                    isolines = update;
+                    bounds = b;
+                    repaint();
+                    assertNull(blocker);
+                    if (next.getModel().isPressed()) {
+                        c.countDown();
+                    } else {
+                        blocker = c;
+                        next.setEnabled(true);
+                    }
+                }
+            });
+            c.await();
+        } catch (InterruptedException  e) {
+            throw new AssertionError(e);            // Stop the test.
+        }
+    }
+
+    /**
+     * Invoked by Swing when user presses the "Next" button.
+     * This method resumes isoline computation.
+     *
+     * @param  event  ignored.
+     */
+    @Override
+    public void actionPerformed(final ActionEvent event) {
+        next.setEnabled(false);
+        if (blocker != null) {
+            blocker.countDown();
+            blocker = null;
+        }
+    }
+
+    /**
+     * Invoked when the "Next" button is kept pressed.
+     * The effect is to start the "fast forward" mode.
+     * This method shall be invoked in Swing thread.
+     *
+     * @param  event  ignored.
+     */
+    private void fastForward(final ActionEvent event) {
+        if (next.getModel().isPressed()) {
+            if (blocker != null) {
+                blocker.countDown();
+                blocker = null;
+            }
+        }
+    }
+
+    /**
+     * Invoked by Swing when the state of the "Next" button (pressed or not) changed.
+     * If the button is pressed one second without being released, then we enter a
+     * "fast forward" mode until the button is released.
+     *
+     * @param  event  ignored.
+     */
+    @Override
+    public void stateChanged(final ChangeEvent event) {
+        final ButtonModel m = (ButtonModel) event.getSource();
+        if (m.isPressed()) {
+            delayedNext.restart();
+        } else {
+            delayedNext.stop();
+        }
+    }
+}