blob: 4f540210bf314d1518ccaff3aa3f055a98497343 [file] [log] [blame]
/*
* 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.isoline;
import java.util.Map;
import java.util.EnumMap;
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(StepsViewer)} constructor body.
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.3
* @since 1.3
* @module
*/
@SuppressWarnings("serial")
public final class StepsViewer extends JComponent implements BiConsumer<String,Isolines>, 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 StepsViewer 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 final Map<PolylineStage,Path2D> isolines;
/**
* The colors to associate to the isoline for each stage.
* Array indices are {@link PolylineStage#ordinal()} values.
*/
private final Color[] stageColors;
/**
* 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 StepsViewer(final RenderedImage data, final Container pane) {
isolines = new EnumMap<>(PolylineStage.class);
stageColors = new Color[] {Color.YELLOW, Color.CYAN, Color.GRAY};
setBackground(Color.BLACK);
setOpaque(true);
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 StepsViewer viewer = new StepsViewer(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);
}
}
/**
* Invoked when the isolines need to be drawn.
*/
@Override
protected void paintComponent(final Graphics g) {
super.paintComponent(g);
final Graphics2D gh = (Graphics2D) g;
gh.setColor(getBackground());
gh.fillRect(0, 0, getWidth(), getHeight());
if (bounds != null) {
gh.setStroke(new BasicStroke(2));
gh.setColor(Color.RED);
gh.draw(bounds);
}
for (final Map.Entry<PolylineStage,Path2D> entry : isolines.entrySet()) {
final int stage = entry.getKey().ordinal();
gh.setStroke(new BasicStroke(stageColors.length - stage));
gh.setColor(stageColors[stage]);
gh.draw(entry.getValue());
}
}
/**
* 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 generator new generator of isolines.
*/
@Override
public void accept(final String title, final Isolines generator) {
final Map<PolylineStage, Path2D> paths = generator.toRawPath();
for (final Map.Entry<PolylineStage,Path2D> entry : paths.entrySet()) {
entry.getValue().transform(sourceToCanvas);
}
try {
final CountDownLatch c = new CountDownLatch(1);
EventQueue.invokeLater(() -> {
Rectangle b = null;
boolean unchanged = true;
for (final PolylineStage stage : PolylineStage.values()) {
final Path2D current = isolines.get(stage);
final Path2D update = paths.get(stage);
if (unchanged && current != update && !(current != null && update != null &&
equal(current.getPathIterator(null), update.getPathIterator(null))))
{
unchanged = false;
}
if (update == null) {
isolines.remove(stage);
} else {
isolines.put(stage, update);
if (stage == PolylineStage.BUFFER) {
b = update.getBounds();
b.x -= PADDING;
b.y -= PADDING;
b.width += PADDING * 2;
b.height += PADDING * 2;
bounds = b;
}
}
}
bounds = b;
if (unchanged) {
stepTitle.setText(title + " (no change)");
c.countDown();
} else {
stepTitle.setText(title);
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();
}
}
}