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
commit 8ceffa01786c878f81c5420428990d58012ac5fe Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Sun Nov 28 12:14:46 2021 +0100 Better rendering of `ImagePropertyExplorer` content when the property value is another image. --- .../sis/gui/coverage/ImagePropertyExplorer.java | 2 +- .../apache/sis/internal/gui/ImageConverter.java | 137 +++++++++++++++------ .../org/apache/sis/internal/gui/PropertyView.java | 62 +++++++--- .../sis/internal/map/coverage/RenderingData.java | 2 + 4 files changed, 147 insertions(+), 56 deletions(-) diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImagePropertyExplorer.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImagePropertyExplorer.java index 39e72dc..eb1208d 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImagePropertyExplorer.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImagePropertyExplorer.java @@ -530,7 +530,7 @@ public class ImagePropertyExplorer extends Widget { /** * Invoked when an image is selected in the tree of image sources. The selected image - * is not necessarily {@link #image} property value; it may be one of its sources. + * is not necessarily the {@link #image} property value; it may be one of its sources. * If no image is explicitly selected, defaults to the root image. */ private void imageSelected(final RenderedImage selected) { diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java index a407d7f..5c96296 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java @@ -26,6 +26,8 @@ import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; import java.awt.image.RenderedImage; import javafx.concurrent.Task; +import javafx.scene.layout.Region; +import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.image.PixelFormat; import javafx.scene.image.PixelWriter; @@ -35,6 +37,7 @@ import org.apache.sis.image.PlanarImage; import org.apache.sis.internal.coverage.j2d.ColorModelFactory; import org.apache.sis.internal.coverage.j2d.ImageUtilities; import org.apache.sis.internal.system.Loggers; +import org.apache.sis.internal.util.Numerics; import org.apache.sis.internal.jdk9.JDK9; import org.apache.sis.util.logging.Logging; import org.apache.sis.measure.NumberRange; @@ -46,26 +49,23 @@ import org.apache.sis.math.Statistics; * This task should be used only for small images (thumbnail) because some potentially costly resources * are created each time. * + * <p>The {@link ImageView} should not be modified by caller. Instead, the {@link #clear(ImageView)} + * method should be invoked for setting the image to null, because this class also manages properties + * associated to the {@link ImageView}.</p> + * * <p>Current implementation returns statistics on sample values as a side-product. - * It may change in any future version.</p> + * The statistics may be null if they were not computed. + * This policy may change in any future version.</p> * * @author Martin Desruisseaux (Geomatys) - * @version 1.1 + * @version 1.2 * @since 1.1 * @module */ final class ImageConverter extends Task<Statistics[]> { /** - * The maximal image width and height, as an arbitrary value that may change in any future version. - * This size should be a typical size expected for the JavaFX control showing the image. - * If the {@link ImageView} is larger than this size, the image will be rescaled by JavaFX. - * The quality is lower than if we had let the "resample" operation do its work at full resolution, - * but {@link PropertyView} should be used only of overview anyway. - */ - private static final int MAX_SIZE = 600; - - /** * Colors to apply on the mask image when that image is overlay on top of another image. + * Current value is a transparent yellow color. */ private static final Map<NumberRange<?>,Color[]> MASK_TRANSPARENCY = JDK9.mapOf( NumberRange.create(0, true, 0, true), new Color[] {ColorModelFactory.TRANSPARENT}, @@ -79,18 +79,23 @@ final class ImageConverter extends Task<Statistics[]> { /** * Pixel coordinates of the region to render, or {@code null} for the whole image. */ - private Rectangle bounds; + private Rectangle sourceAOI; /** - * Size of the image actually rendered. This is the {@link #bounds} size, - * unless that size is greater than {@link #MAX_SIZE} in which case it is scaled down. + * On input, the canvas size. On output, the actual image size rendered in the canvas. + * One of the width or height may be modified on output for preserving image proportions. */ private int width, height; /** + * Value to give to {@link ImageView} x/y properties after the image has been created. + */ + private int xpos, ypos; + + /** * Where to write the image. This will be updated in JavaFX thread. */ - private final ImageView canvas; + private final ImageView view; /** * The ARGB values to be copied in the JavaFX image. @@ -100,38 +105,85 @@ final class ImageConverter extends Task<Statistics[]> { /** * Creates a new task for converting the given image. */ - ImageConverter(final RenderedImage source, final Rectangle bounds, final ImageView canvas) { - this.source = source; - this.bounds = bounds; - this.canvas = canvas; + ImageConverter(final RenderedImage source, final Rectangle sourceAOI, final ImageView view, final Region canvas) { + this.source = source; + this.sourceAOI = sourceAOI; + this.view = view; + this.width = Numerics.clamp(Math.round(canvas.getWidth())); + this.height = Numerics.clamp(Math.round(canvas.getHeight())); + } + + /** + * Sets the image property to {@code null} on the given view. + * This method also clears other properties managed by {@link ImageConverter}. + * + * @param view the view on which to set the image property to {@code null}. + */ + public static void clear(final ImageView view) { + view.setImage(null); + view.setUserData(null); + } + + /** + * Returns {@code true} if the {@link #call()} method needs to be invoked in a background thread. + * This method may return {@code false} because the canvas size is zero, or because current content can be reused. + * The content can be reused if the canvas size did not changed, or changed in a way that does not require repaint + * (i.e. if the size that changed is the size that was already too wide for fitting the image inside the canvas). + * + * @param newAOI whether {@link #sourceAOI} had a different value during previous rendering. + * @return whether this task needs to be run. + */ + public boolean needsRun(final boolean newAOI) { + if (width <= 0 || height <= 0) { + return false; + } + if (!newAOI && view.getUserData() == source) { + final Image image = view.getImage(); + final double dx = width - image.getWidth(); + final double dy = height - image.getHeight(); + /* + * Conditions: the image is not larger than the canvas (dx and dy >= 0) + * and at least one image side has the maximal size (dx or dy == 0). + */ + if (dx >= 0 && dy >= 0 && (dx == 0 || dy == 0)) { + view.setX(dx / 2); + view.setY(dy / 2); + return false; + } + } + return true; } /** * Prepares the ARGB values to be written in the JavaFX image. * The task opportunistically returns statistics on all bands of the source image. - * Those statistics were used for stretching the color ramp before to paint the JavaFX image. + * They are the statistics used for stretching the color ramp before to paint the JavaFX image. + * + * @return statistics on sample value, or {@code null} if not computed. */ @Override protected Statistics[] call() { - if (bounds == null) { - bounds = ImageUtilities.getBounds(source); + if (sourceAOI == null) { + sourceAOI = ImageUtilities.getBounds(source); } - width = Math.min(bounds.width, MAX_SIZE); - height = Math.min(bounds.height, MAX_SIZE); - final double scale = Math.max(width / (double) bounds.width, - height / (double) bounds.height); /* * Use a uniform scale. At least one of `width` or `height` will be unchanged. + * We favor showing fully the image instead of filling all the canvas space. */ - width = (int) Math.round(scale * bounds.width); - height = (int) Math.round(scale * bounds.height); + final double scale = Math.min(width / (double) sourceAOI.width, + height / (double) sourceAOI.height); + xpos = (width - (width = Numerics.clamp(Math.round(scale * sourceAOI.width )))) / 2; + ypos = (height - (height = Numerics.clamp(Math.round(scale * sourceAOI.height)))) / 2; + if (width <= 0 || height <= 0) { + return null; + } final AffineTransform toCanvas = AffineTransform.getScaleInstance(scale, scale); - toCanvas.translate(-bounds.x, -bounds.y); + toCanvas.translate(-sourceAOI.x, -sourceAOI.y); /* - * Stretch color ramp using statistics on the source image before to pain on JavaFX image. + * Stretch color ramp using statistics about source image before to paint on JavaFX image. */ final ImageProcessor processor = new ImageProcessor(); - final Statistics[] statistics = processor.valueOfStatistics(source, bounds, (DoubleUnaryOperator[]) null); + final Statistics[] statistics = processor.valueOfStatistics(source, sourceAOI, (DoubleUnaryOperator[]) null); final RenderedImage image = processor.stretchColorRamp(source, JDK9.mapOf("multStdDev", 3, "statistics", statistics)); final RenderedImage mask = getMask(processor); final BufferedImage buffer = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB_PRE); @@ -168,16 +220,20 @@ final class ImageConverter extends Task<Statistics[]> { */ @Override protected void succeeded() { - WritableImage destination = (WritableImage) canvas.getImage(); - if (destination == null || destination.getWidth() != width || destination.getHeight() != height) { - destination = new WritableImage(width, height); + WritableImage destination = null; + if (data != null) { + destination = (WritableImage) view.getImage(); + if (destination == null || destination.getWidth() != width || destination.getHeight() != height) { + destination = new WritableImage(width, height); + } + final PixelWriter writer = destination.getPixelWriter(); + writer.setPixels(0, 0, width, height, PixelFormat.getIntArgbPreInstance(), data, 0, width); + data = null; } - final PixelWriter writer = destination.getPixelWriter(); - writer.setPixels(0, 0, width, height, PixelFormat.getIntArgbPreInstance(), data, 0, width); - data = null; - canvas.setImage(destination); - canvas.setFitWidth (bounds.width); - canvas.setFitHeight(bounds.height); + view.setImage(destination); + view.setUserData(source); + view.setX(xpos); + view.setY(ypos); } /** @@ -186,6 +242,7 @@ final class ImageConverter extends Task<Statistics[]> { @Override protected void failed() { data = null; + clear(view); } /** diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/PropertyView.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/PropertyView.java index 50879d1..a57ff74 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/PropertyView.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/PropertyView.java @@ -29,6 +29,8 @@ import java.text.ParsePosition; import java.text.ParseException; import java.awt.Rectangle; import java.awt.image.RenderedImage; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; import javafx.beans.property.ObjectProperty; import javafx.concurrent.Task; import javafx.geometry.Insets; @@ -52,7 +54,8 @@ import org.apache.sis.util.resources.Vocabulary; * A viewer for property value. The property may be of various class (array, image, <i>etc</i>). * If the type is unrecognized, the property is shown as text. * - * <p>This class extends {@link CompoundFormat} for implementation convenience only.</p> + * <p>This class extends {@link CompoundFormat} and implements {@code ChangeListener} for + * implementation convenience only. Users should not rely on this implementation details.</p> * * @author Martin Desruisseaux (Geomatys) * @version 1.2 @@ -60,7 +63,7 @@ import org.apache.sis.util.resources.Vocabulary; * @module */ @SuppressWarnings({"serial","CloneableImplementsClone"}) // Not intended to be serialized. -public final class PropertyView extends CompoundFormat<Object> { +public final class PropertyView extends CompoundFormat<Object> implements ChangeListener<Number> { /** * The current property value. This is used for detecting changes. */ @@ -138,6 +141,8 @@ public final class PropertyView extends CompoundFormat<Object> { if (background != null) { imageCanvas.backgroundProperty().bind(background); } + imageCanvas.widthProperty() .addListener(this); + imageCanvas.heightProperty().addListener(this); } /** @@ -210,7 +215,8 @@ public final class PropertyView extends CompoundFormat<Object> { * @param visibleBounds if the property is an image, currently visible region. Can be {@code null}. */ public void set(final Object newValue, final Rectangle visibleBounds) { - if (newValue != value || !Objects.equals(visibleBounds, visibleImageBounds)) { + final boolean boundsChanged = !Objects.equals(visibleBounds, visibleImageBounds); + if (newValue != value || boundsChanged) { if (runningTask != null) { runningTask.cancel(BackgroundThreads.NO_INTERRUPT_DURING_IO); runningTask = null; @@ -220,7 +226,7 @@ public final class PropertyView extends CompoundFormat<Object> { if (newValue == null) { content = null; } else if (newValue instanceof RenderedImage) { - content = setImage((RenderedImage) newValue); + content = setImage((RenderedImage) newValue, boundsChanged); } else if (newValue instanceof Throwable) { content = setText((Throwable) newValue); } else if (newValue instanceof Collection<?>) { @@ -278,8 +284,11 @@ public final class PropertyView extends CompoundFormat<Object> { /** * Sets the property value to the given image. + * + * @param image the property value to set, or {@code null}. + * @param boundsChanged whether {@link #visibleImageBounds} changed since last call. */ - private Node setImage(final RenderedImage image) { + private Node setImage(final RenderedImage image, final boolean boundsChanged) { ImageView node = imageView; if (node == null) { node = new ImageView(); @@ -310,14 +319,16 @@ public final class PropertyView extends CompoundFormat<Object> { imagePane.setHgap(0); imageView = node; } - final ImageConverter converter = new ImageConverter(image, visibleImageBounds, node); - converter.setOnSucceeded((e) -> taskCompleted(converter.getValue())); - converter.setOnFailed((e) -> { - taskCompleted(null); - view.set(setText(e.getSource().getException())); - }); - runningTask = converter; - BackgroundThreads.execute(converter); + final ImageConverter converter = new ImageConverter(image, visibleImageBounds, node, imageCanvas); + if (converter.needsRun(boundsChanged)) { + converter.setOnSucceeded((e) -> taskCompleted(converter.getValue())); + converter.setOnFailed((e) -> { + taskCompleted(null); + view.set(setText(e.getSource().getException())); + }); + runningTask = converter; + BackgroundThreads.execute(converter); + } return imagePane; } @@ -349,6 +360,23 @@ public final class PropertyView extends CompoundFormat<Object> { } sampleValueRange.setText(range); meanValue.setText(mean); + changed(null, null, null); // For centering the image. + } + + /** + * Invoked when the image canvas size changed. If the previous canvas size was 0, the image could not be rendered + * during the previous call to {@link #setImage(RenderedImage, boolean)}, so we need to call that method again now + * that the image size is known. In addition we also need to move the image to canvas center. + * + * @param property the image canvas property that changed (width or height). + * @param oldValue the old width or height value. + * @param newValue the new width or height value. + */ + @Override + public void changed(final ObservableValue<? extends Number> property, final Number oldValue, final Number newValue) { + if (value instanceof RenderedImage) { + setImage((RenderedImage) value, false); + } } /** @@ -357,7 +385,11 @@ public final class PropertyView extends CompoundFormat<Object> { public void clear() { value = null; view.set(null); - if (textView != null) textView .setText (null); - if (imageView != null) imageView.setImage(null); + if (textView != null) { + textView .setText (null); + } + if (imageView != null) { + ImageConverter.clear(imageView); + } } } diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java index a4bc23a..6cba9de 100644 --- a/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java +++ b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java @@ -93,6 +93,8 @@ import org.apache.sis.portrayal.PlanarCanvas; // For javadoc. * in pixel coordinates, the translation terms in {@code resampledToDisplay} transform should stay integers. * * <p>Current version of this class does not perform a special case for {@link BufferedImage}. + * It may not be desirable because interpolations would not be applied in the same way, except + * when SIS {@link ImageProcessor} would have interpolated RGB color values anyway like Java2D. * We wait to see if this class works well in the general case before doing special cases.</p> * * @author Martin Desruisseaux (Geomatys)