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 <martin.desruisse...@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(); + } + } +}