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 f691d87e35 Store sample dimensions in a `RenderedImage` property. Use that property instead of argument value in `ImageProcessor`. f691d87e35 is described below commit f691d87e35689303ff1dbcd9995f85f42673eb6d Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Wed Mar 29 20:31:06 2023 +0200 Store sample dimensions in a `RenderedImage` property. Use that property instead of argument value in `ImageProcessor`. https://issues.apache.org/jira/browse/SIS-577 --- .../org/apache/sis/coverage/grid/GridCoverage.java | 37 +++--- .../apache/sis/coverage/grid/GridCoverage2D.java | 2 + .../sis/coverage/grid/GridCoverageBuilder.java | 13 ++- .../sis/coverage/grid/GridCoverageProcessor.java | 118 +++++++++++++------ .../apache/sis/coverage/grid/ImageRenderer.java | 77 ++++++++----- .../sis/coverage/grid/ResampledGridCoverage.java | 3 +- .../java/org/apache/sis/image/AnnotatedImage.java | 12 +- .../java/org/apache/sis/image/BandSelectImage.java | 14 ++- .../apache/sis/image/BandedSampleConverter.java | 89 +++++++++++---- .../main/java/org/apache/sis/image/Colorizer.java | 28 +++-- .../java/org/apache/sis/image/ImageAdapter.java | 6 +- .../java/org/apache/sis/image/ImageProcessor.java | 126 +++++++++++++++++---- .../java/org/apache/sis/image/PlanarImage.java | 17 ++- .../java/org/apache/sis/image/RecoloredImage.java | 44 ++++--- .../java/org/apache/sis/image/ResampledImage.java | 10 +- .../java/org/apache/sis/image/UserProperties.java | 124 ++++++++++++++++++++ .../java/org/apache/sis/image/Visualization.java | 56 +++++---- .../sis/internal/coverage/SampleDimensions.java | 37 ++++-- .../coverage/grid/ConvertedGridCoverageTest.java | 24 +++- .../org/apache/sis/image/ImageProcessorTest.java | 31 ++++- .../apache/sis/image/StatisticsCalculatorTest.java | 2 +- .../sis/internal/map/coverage/RenderingData.java | 34 ++++-- .../sis/internal/storage/esri/RasterStore.java | 8 +- 23 files changed, 696 insertions(+), 216 deletions(-) diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage.java index d1f6a544a8..fbea3ecdb7 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage.java +++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage.java @@ -20,7 +20,6 @@ import java.util.Map; import java.util.List; import java.util.Locale; import java.util.Optional; -import java.awt.image.ColorModel; import java.awt.image.RenderedImage; import org.opengis.geometry.Envelope; import org.opengis.geometry.DirectPosition; @@ -36,8 +35,7 @@ import org.apache.sis.coverage.SampleDimension; import org.apache.sis.coverage.SubspaceNotSpecifiedException; import org.apache.sis.image.DataType; import org.apache.sis.image.ImageProcessor; -import org.apache.sis.internal.coverage.j2d.ImageUtilities; -import org.apache.sis.internal.coverage.j2d.ColorModelBuilder; +import org.apache.sis.internal.coverage.SampleDimensions; import org.apache.sis.util.collection.DefaultTreeTable; import org.apache.sis.util.collection.TableColumn; import org.apache.sis.util.collection.TreeTable; @@ -58,7 +56,7 @@ import org.opengis.coverage.CannotEvaluateException; * * @author Martin Desruisseaux (IRD, Geomatys) * @author Johann Sorel (Geomatys) - * @version 1.3 + * @version 1.4 * @since 1.0 */ public abstract class GridCoverage extends BandedCoverage { @@ -206,6 +204,19 @@ public abstract class GridCoverage extends BandedCoverage { return ranges; } + /** + * Returns the background value of each sample dimension. + * The array length is the number of sample dimensions (bands). + * Some array element may be {@code null} if the corresponding band has no background value. + * + * @return background value of each sample dimension. + * + * @see SampleDimension#getBackground() + */ + final Number[] getBackground() { + return SampleDimensions.backgrounds(sampleDimensions); + } + /** * Returns the data type identifying the primitive type used for storing sample values in each band. * We assume no packed sample model (e.g. no packing of 4 byte ARGB values in a single 32-bits integer). @@ -289,6 +300,9 @@ public abstract class GridCoverage extends BandedCoverage { /** * Creates a new image of the given data type which will compute values using the given converters. + * The {@link #sampleDimensions} declared in this {@code GridCoverage} instances shall be applicable + * to the returned image, as it will be assigned to the image property + * {@value org.apache.sis.image.PlanarImage#SAMPLE_DIMENSIONS_KEY}. * * @param source the image for which to convert sample values. * @param bandType the type of data in the bands resulting from conversion of given image. @@ -299,17 +313,12 @@ public abstract class GridCoverage extends BandedCoverage { final RenderedImage convert(final RenderedImage source, final DataType bandType, final MathTransform1D[] converters, final ImageProcessor processor) { - final int visibleBand = Math.max(0, ImageUtilities.getVisibleBand(source)); - final ColorModelBuilder colorizer = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE); - final ColorModel colors; - if (colorizer.initialize(source.getSampleModel(), sampleDimensions[visibleBand]) || - colorizer.initialize(source.getColorModel())) - { - colors = colorizer.createColorModel(bandType.toDataBufferType(), sampleDimensions.length, visibleBand); - } else { - colors = ColorModelBuilder.NULL_COLOR_MODEL; + try { + SampleDimensions.CONVERTED_BANDS.set(sampleDimensions); + return processor.convert(source, getRanges(), converters, bandType); + } finally { + SampleDimensions.CONVERTED_BANDS.remove(); } - return processor.convert(source, getRanges(), converters, bandType, colors); } /** diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java index d48f05c83e..7bbcd934b1 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java +++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java @@ -471,6 +471,8 @@ public class GridCoverage2D extends GridCoverage { /** * Creates a grid coverage that contains real values or sample values, * depending if {@code converted} is {@code true} or {@code false} respectively. + * This method is invoked by the default implementation of {@link #forConvertedValues(boolean)} + * when first needed. * * @param converted {@code true} for a coverage containing converted values, * or {@code false} for a coverage containing packed values. diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java index c437c4ea81..ca04e9a09e 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java +++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java @@ -33,6 +33,7 @@ import java.awt.image.SampleModel; import java.awt.image.WritableRaster; import org.opengis.geometry.Envelope; import org.opengis.referencing.operation.TransformException; +import org.apache.sis.image.PlanarImage; import org.apache.sis.coverage.SampleDimension; import org.apache.sis.internal.coverage.j2d.ColorModelBuilder; import org.apache.sis.internal.coverage.j2d.ImageUtilities; @@ -87,7 +88,7 @@ import org.apache.sis.util.resources.Errors; * * @author Johann Sorel (Geomatys) * @author Martin Desruisseaux (Geomatys) - * @version 1.2 + * @version 1.4 * * @see GridCoverage2D * @see SampleDimension.Builder @@ -169,12 +170,13 @@ public class GridCoverageBuilder { * @see #addImageProperty(String, Object) */ @SuppressWarnings("UseOfObsoleteCollectionType") - private Hashtable<String,Object> properties; + private final Hashtable<String,Object> properties; /** * Creates an initially empty builder. */ public GridCoverageBuilder() { + properties = new Hashtable<>(); } /** @@ -419,9 +421,6 @@ public class GridCoverageBuilder { public GridCoverageBuilder addImageProperty(final String key, final Object value) { ArgumentChecks.ensureNonNull("key", key); ArgumentChecks.ensureNonNull("value", value); - if (properties == null) { - properties = new Hashtable<>(); - } if (properties.putIfAbsent(key, value) != null) { throw new IllegalArgumentException(Errors.format(Errors.Keys.ElementAlreadyPresent_1, key)); } @@ -482,6 +481,9 @@ public class GridCoverageBuilder { * Create an image from the raster. We favor BufferedImage instance when possible, * and fallback on TiledImage only if the BufferedImage cannot be created. */ + if (bands != null) { + properties.put(PlanarImage.SAMPLE_DIMENSIONS_KEY, bands.toArray(SampleDimension[]::new)); + } if (raster instanceof WritableRaster) { final WritableRaster wr = (WritableRaster) raster; if (colors != null && (wr.getMinX() | wr.getMinY()) == 0) { @@ -492,6 +494,7 @@ public class GridCoverageBuilder { } else { image = new TiledImage(properties, colors, raster.getWidth(), raster.getHeight(), 0, 0, raster); } + properties.remove(PlanarImage.SAMPLE_DIMENSIONS_KEY); } /* * At this point `image` shall be non-null but `bands` may still be null (it is okay). diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java index 31465949a6..b915d36752 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java +++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java @@ -36,6 +36,7 @@ import org.apache.sis.coverage.RegionOfInterest; import org.apache.sis.coverage.SampleDimension; import org.apache.sis.coverage.SubspaceNotSpecifiedException; import org.apache.sis.image.DataType; +import org.apache.sis.image.Colorizer; import org.apache.sis.image.ImageProcessor; import org.apache.sis.image.Interpolation; import org.apache.sis.internal.coverage.MultiSourcesArgument; @@ -57,6 +58,8 @@ import org.apache.sis.measure.NumberRange; * </li><li> * {@linkplain #setFillValues(Number...) Fill values} to use for cells that cannot be computed. * </li><li> + * {@linkplain #setColorizer(Colorizer) Colorization algorithm} to apply for colorizing a computed image. + * </li><li> * {@linkplain #setPositionalAccuracyHints(Quantity...) Positional accuracy hints} * for enabling the use of faster algorithm when a lower accuracy is acceptable. * </li><li> @@ -86,11 +89,27 @@ public class GridCoverageProcessor implements Cloneable { private static final WeakHashSet<ImageProcessor> PROCESSORS = new WeakHashSet<>(ImageProcessor.class); /** - * Returns an unique instance of the given processor. Both the given and the returned processors - * shall be unmodified, because they may be shared by many {@link GridCoverage} instances. + * Returns a unique instance of the given processor. Both the given and the returned processors shall not + * be modified after this method call, because they may be shared by many {@link GridCoverage} instances. + * It implies that the given processor shall <em>not</em> be {@link #imageProcessor}. It must be a clone. + * + * @param clone a clone of {@link #imageProcessor} for which to return a unique instance. + * @return a unique instance of the given clone. Shall not be modified by the caller. */ - static ImageProcessor unique(final ImageProcessor image) { - return PROCESSORS.unique(image); + static ImageProcessor unique(final ImageProcessor clone) { + return PROCESSORS.unique(clone); + } + + /** + * Returns a unique instance of the current state of {@link #imageProcessor}. + * Callers shall not modify the returned object because it may be shared by many {@link GridCoverage} instances. + */ + private ImageProcessor snapshot() { + ImageProcessor shared = PROCESSORS.get(imageProcessor); + if (shared == null) { + shared = unique(imageProcessor.clone()); + } + return shared; } /** @@ -150,6 +169,64 @@ public class GridCoverageProcessor implements Cloneable { imageProcessor.setInterpolation(method); } + /** + * Returns the values to use for pixels that cannot be computed. + * The default implementation delegates to the image processor. + * + * @return fill values to use for pixels that cannot be computed, or {@code null} for the defaults. + * + * @see ImageProcessor#getFillValues() + * + * @since 1.2 + */ + public Number[] getFillValues() { + return imageProcessor.getFillValues(); + } + + /** + * Sets the values to use for pixels that cannot be computed. + * The default implementation delegates to the image processor. + * + * @param values fill values to use for pixels that cannot be computed, or {@code null} for the defaults. + * + * @see ImageProcessor#setFillValues(Number...) + * + * @since 1.2 + */ + public void setFillValues(final Number... values) { + imageProcessor.setFillValues(values); + } + + /** + * Returns the colorization algorithm to apply on computed images. + * The default implementation delegates to the image processor. + * + * @return colorization algorithm to apply on computed image, or {@code null} for default. + * + * @see ImageProcessor#getColorizer() + * + * @since 1.4 + */ + public Colorizer getColorizer() { + return imageProcessor.getColorizer(); + } + + /** + * Sets the colorization algorithm to apply on computed images. + * The colorizer is used by {@link #convert(GridCoverage, MathTransform1D[], Function) convert(…)} + * and {@link #aggregateRanges(GridCoverage...) aggregateRanges(…)} operations among others. + * The default implementation delegates to the image processor. + * + * @param colorizer colorization algorithm to apply on computed image, or {@code null} for default. + * + * @see ImageProcessor#setColorizer(Colorizer) + * + * @since 1.4 + */ + public void setColorizer(final Colorizer colorizer) { + imageProcessor.setColorizer(colorizer); + } + /** * Returns hints about the desired positional accuracy, in "real world" units or in pixel units. * The default implementation delegates to the image processor. @@ -245,34 +322,6 @@ public class GridCoverageProcessor implements Cloneable { optimizations.addAll(enabled); } - /** - * Returns the values to use for pixels that cannot be computed. - * The default implementation delegates to the image processor. - * - * @return fill values to use for pixels that cannot be computed, or {@code null} for the defaults. - * - * @see ImageProcessor#getFillValues() - * - * @since 1.2 - */ - public Number[] getFillValues() { - return imageProcessor.getFillValues(); - } - - /** - * Sets the values to use for pixels that cannot be computed. - * The default implementation delegates to the image processor. - * - * @param values fill values to use for pixels that cannot be computed, or {@code null} for the defaults. - * - * @see ImageProcessor#setFillValues(Number...) - * - * @since 1.2 - */ - public void setFillValues(final Number... values) { - imageProcessor.setFillValues(values); - } - /** * Applies a mask defined by a region of interest (ROI). If {@code maskInside} is {@code true}, * then all pixels inside the given ROI are set to the {@linkplain #getFillValues() fill values}. @@ -359,7 +408,7 @@ public class GridCoverageProcessor implements Cloneable { builder.clear(); } return new ConvertedGridCoverage(source, UnmodifiableArrayList.wrap(targetBands), - converters, true, unique(imageProcessor), true); + converters, true, snapshot(), true); } /** @@ -484,6 +533,7 @@ public class GridCoverageProcessor implements Cloneable { } final GridCoverage resampled; try { + // `ResampledGridCoverage` will create itself a clone of `imageProcessor`. resampled = ResampledGridCoverage.create(source, target, imageProcessor, allowOperationReplacement); } catch (IllegalGridGeometryException e) { final Throwable cause = e.getCause(); @@ -689,7 +739,7 @@ public class GridCoverageProcessor implements Cloneable { if (aggregate.isIdentity()) { return aggregate.sources()[0]; } - return new BandAggregateGridCoverage(aggregate, imageProcessor); + return new BandAggregateGridCoverage(aggregate, snapshot()); } /** diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java index 79eddf520c..9fde21c79b 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java +++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java @@ -59,6 +59,7 @@ import static java.lang.Math.multiplyExact; import static java.lang.Math.incrementExact; import static java.lang.Math.toIntExact; import static org.apache.sis.image.PlanarImage.GRID_GEOMETRY_KEY; +import static org.apache.sis.image.PlanarImage.SAMPLE_DIMENSIONS_KEY; /** @@ -98,7 +99,7 @@ import static org.apache.sis.image.PlanarImage.GRID_GEOMETRY_KEY; * Support for tiled images will be added in a future version. * * @author Martin Desruisseaux (Geomatys) - * @version 1.3 + * @version 1.4 * * @see GridCoverage#render(GridExtent) * @@ -146,9 +147,10 @@ public class ImageRenderer { * Location of the first image pixel relative to the grid coverage extent. The (0,0) offset means that the first pixel * in the {@code sliceExtent} (specified at construction time) is the first pixel in the whole {@link GridCoverage}. * - * <div class="note"><b>Note:</b> if those offsets exceed 32 bits integer capacity, then it may not be possible to build - * an image for given {@code sliceExtent} from a single {@link DataBuffer}, because accessing sample values would exceed - * the capacity of index in Java arrays. In those cases the image needs to be tiled.</div> + * <h4>Implementation note</h4> + * If those offsets exceed 32 bits integer capacity, then it may not be possible to build an image + * for given {@code sliceExtent} from a single {@link DataBuffer}, because accessing sample values + * would exceed the capacity of index in Java arrays. In those cases the image needs to be tiled. */ private final long offsetX, offsetY; @@ -461,9 +463,14 @@ public class ImageRenderer { } /** - * Returns the value associated to the given property. By default the only property is - * {@value org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY}, but more properties can - * be added by calls to {@link #addProperty(String, Object)}. + * Returns the value associated to the given property. + * The properties recognized by current implementation are: + * + * <ul> + * <li>{@value org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY}.</li> + * <li>{@value org.apache.sis.image.PlanarImage#SAMPLE_DIMENSIONS_KEY}.</li> + * <li>Any property added by calls to {@link #addProperty(String, Object)}.</li> + * </ul> * * @param key the property for which to get a value. * @return value associated to the given property, or {@code null} if none. @@ -471,8 +478,9 @@ public class ImageRenderer { * @since 1.1 */ public Object getProperty(final String key) { - if (GRID_GEOMETRY_KEY.equals(key)) { - return getImageGeometry(GridCoverage2D.BIDIMENSIONAL); + switch (key) { + case GRID_GEOMETRY_KEY: return getImageGeometry(GridCoverage2D.BIDIMENSIONAL); + case SAMPLE_DIMENSIONS_KEY: return bands.clone(); } return (properties != null) ? properties.get(key) : null; } @@ -491,7 +499,7 @@ public class ImageRenderer { public void addProperty(final String key, final Object value) { ArgumentChecks.ensureNonNull("key", key); ArgumentChecks.ensureNonNull("value", value); - if (!GRID_GEOMETRY_KEY.equals(key)) { + if (!(GRID_GEOMETRY_KEY.equals(key) || SAMPLE_DIMENSIONS_KEY.equals(key))) { if (properties == null) { properties = new Hashtable<>(); } @@ -635,13 +643,15 @@ public class ImageRenderer { * All other bands, if any, will exist in the raster but be ignored at display time. * The default value is 0, the first (and often only) band. * - * <div class="note"><b>Implementation note:</b> - * an {@link java.awt.image.IndexColorModel} will be used for displaying the image.</div> + * <h4>Implementation note</h4> + * An {@link java.awt.image.IndexColorModel} will be used for displaying the image. * * @param band the band to use for display purpose. * @throws IllegalArgumentException if the given band is not between 0 (inclusive) * and {@link #getNumBands()} (exclusive). * + * @see org.apache.sis.image.Colorizer.Target#getVisibleBand() + * * @since 1.2 */ public void setVisibleBand(final int band) { @@ -725,10 +735,12 @@ public class ImageRenderer { * The image upper-left corner is located at the position given by {@link #getBounds()}. * The two-dimensional {@linkplain #getImageGeometry(int) image geometry} is stored as * a property associated to the {@value org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY} key. + * The sample dimensions are stored as a property associated to the + * {@value org.apache.sis.image.PlanarImage#SAMPLE_DIMENSIONS_KEY} key. * * <p>The default implementation returns an instance of {@link java.awt.image.WritableRenderedImage} - * if the {@link #createRaster()} return value is an instance of {@link WritableRaster}, or a read-only - * {@link RenderedImage} otherwise.</p> + * if the {@link #createRaster()} return value is an instance of {@link WritableRaster}, + * or a read-only {@link RenderedImage} otherwise.</p> * * @return the image. * @throws IllegalStateException if no {@code setData(…)} method has been invoked before this method call. @@ -758,12 +770,13 @@ public class ImageRenderer { } final WritableRaster wr = (raster instanceof WritableRaster) ? (WritableRaster) raster : null; if (wr != null && colors != null && (imageX | imageY) == 0) { - return new Untiled(colors, wr, properties, imageGeometry, supplier); + return new Untiled(colors, wr, properties, imageGeometry, supplier, bands); } if (properties == null) { properties = new Hashtable<>(); } properties.putIfAbsent(GRID_GEOMETRY_KEY, (supplier != null) ? new DeferredProperty(supplier) : imageGeometry); + properties.putIfAbsent(SAMPLE_DIMENSIONS_KEY, bands); if (wr != null) { return new WritableTiledImage(properties, colors, width, height, 0, 0, wr); } else { @@ -791,16 +804,22 @@ public class ImageRenderer { */ private SliceGeometry supplier; + /** + * The value associated to the {@value org.apache.sis.image.PlanarImage#SAMPLE_DIMENSIONS_KEY} key. + */ + private final SampleDimension[] bands; + /** * Creates a new buffered image wrapping the given raster. */ @SuppressWarnings("UseOfObsoleteCollectionType") Untiled(final ColorModel colors, final WritableRaster raster, final Hashtable<?,?> properties, - final GridGeometry geometry, final SliceGeometry supplier) + final GridGeometry geometry, final SliceGeometry supplier, final SampleDimension[] bands) { super(colors, raster, false, properties); this.geometry = geometry; this.supplier = supplier; + this.bands = bands; } /** @@ -808,7 +827,8 @@ public class ImageRenderer { */ @Override public String[] getPropertyNames() { - return ArraysExt.concatenate(super.getPropertyNames(), new String[] {GRID_GEOMETRY_KEY}); + return ArraysExt.concatenate(super.getPropertyNames(), + new String[] {GRID_GEOMETRY_KEY, SAMPLE_DIMENSIONS_KEY}); } /** @@ -820,19 +840,22 @@ public class ImageRenderer { */ @Override public Object getProperty(final String key) { - if (!GRID_GEOMETRY_KEY.equals(key)) { - return super.getProperty(key); - } - synchronized (this) { - if (geometry == null) { - final SliceGeometry s = supplier; - if (s != null) { - supplier = null; // Let GC do its work. - geometry = s.apply(this); + switch (key) { + default: return super.getProperty(key); + case SAMPLE_DIMENSIONS_KEY: return bands.clone(); + case GRID_GEOMETRY_KEY: { + synchronized (this) { + if (geometry == null) { + final SliceGeometry s = supplier; + if (s != null) { + supplier = null; // Let GC do its work. + geometry = s.apply(this); + } + } + return geometry; } } } - return geometry; } } } diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java index 00d9b52850..997b357e3b 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java +++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java @@ -32,7 +32,6 @@ import org.apache.sis.image.ImageProcessor; import org.apache.sis.geometry.GeneralEnvelope; import org.apache.sis.internal.feature.Resources; import org.apache.sis.internal.util.DoubleDouble; -import org.apache.sis.internal.coverage.SampleDimensions; import org.apache.sis.internal.referencing.DirectPositionView; import org.apache.sis.internal.referencing.ExtendedPrecisionMatrix; import org.apache.sis.referencing.operation.transform.LinearTransform; @@ -113,7 +112,7 @@ final class ResampledGridCoverage extends DerivedGridCoverage { * NaN for floating point values. */ processor = processor.clone(); - processor.setFillValues(SampleDimensions.backgrounds(getSampleDimensions())); + processor.setFillValues(getBackground()); changeOfCRS.setAccuracyOf(processor); imageProcessor = GridCoverageProcessor.unique(processor); final Dimension s = imageProcessor.getInterpolation().getSupportSize(); diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/AnnotatedImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/AnnotatedImage.java index 4a661a3e9a..577ca19dfc 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/image/AnnotatedImage.java +++ b/core/sis-feature/src/main/java/org/apache/sis/image/AnnotatedImage.java @@ -52,9 +52,9 @@ import org.apache.sis.internal.util.Strings; * <p>The computation results are cached by this class. The cache strategy assumes that the * property value depend only on sample values, not on properties of the source image.</p> * - * <div class="note"><b>Design note:</b> - * most non-abstract methods are final because {@link PixelIterator} (among others) relies - * on the fact that it can unwrap this image and still get the same pixel values.</div> + * <h2>Design note</h2> + * Most non-abstract methods are final because {@link PixelIterator} (among others) relies + * on the fact that it can unwrap this image and still get the same pixel values. * * @author Martin Desruisseaux (Geomatys) * @version 1.2 @@ -258,9 +258,9 @@ abstract class AnnotatedImage extends ImageAdapter { * i.e. for distinguishing between two {@code AnnotatedImage} instances that are identical * except for subclass-defined parameters. * - * <div class="note"><b>API note:</b> - * the return value is an array because there is typically one parameter value per band. - * This method will not modify the returned array.</div> + * <h4>API note</h4> + * The return value is an array because there is typically one parameter value per band. + * This method will not modify the returned array. * * @return subclass specific extra parameter, or {@code null} if none. */ diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java index 712c910420..43ade8f563 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java +++ b/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java @@ -50,8 +50,16 @@ final class BandSelectImage extends SourceAlignedImage { * @see #getProperty(String) */ private static final Set<String> INHERITED_PROPERTIES = Set.of( - GRID_GEOMETRY_KEY, POSITIONAL_ACCURACY_KEY, // Properties to forward as-is. - SAMPLE_RESOLUTIONS_KEY, STATISTICS_KEY); // Properties to forward after band reduction. + GRID_GEOMETRY_KEY, POSITIONAL_ACCURACY_KEY, // Properties to forward as-is. + SAMPLE_DIMENSIONS_KEY, SAMPLE_RESOLUTIONS_KEY, STATISTICS_KEY); // Properties to forward after band reduction. + + /** + * Inherited properties that require band reduction. + * Shall be a subset of {@link #INHERITED_PROPERTIES}. + * All values must be arrays. + */ + private static final Set<String> REDUCED_PROPERTIES = Set.of( + SAMPLE_DIMENSIONS_KEY, SAMPLE_RESOLUTIONS_KEY, STATISTICS_KEY); /** * The selected bands. @@ -144,7 +152,7 @@ final class BandSelectImage extends SourceAlignedImage { */ private static Object getProperty(final RenderedImage source, final String key, final int[] bands) { final Object value = source.getProperty(key); - if (value != null && (key.equals(SAMPLE_RESOLUTIONS_KEY) || key.equals(STATISTICS_KEY))) { + if (value != null && REDUCED_PROPERTIES.contains(key)) { final Class<?> componentType = value.getClass().getComponentType(); if (componentType != null) { final Object reduced = Array.newInstance(componentType, bands.length); diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java b/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java index ab8ee294a0..baab75fe89 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java +++ b/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java @@ -32,16 +32,19 @@ import java.lang.reflect.Array; import org.opengis.referencing.operation.MathTransform1D; import org.opengis.referencing.operation.TransformException; import org.opengis.referencing.operation.NoninvertibleTransformException; -import org.apache.sis.internal.coverage.j2d.ColorModelFactory; +import org.apache.sis.internal.coverage.j2d.ColorModelBuilder; import org.apache.sis.internal.coverage.j2d.ImageLayout; import org.apache.sis.internal.coverage.j2d.ImageUtilities; import org.apache.sis.internal.coverage.j2d.TileOpExecutor; import org.apache.sis.internal.coverage.j2d.WriteSupport; +import org.apache.sis.internal.coverage.SampleDimensions; +import org.apache.sis.internal.util.UnmodifiableArrayList; import org.apache.sis.util.Numbers; import org.apache.sis.util.Disposable; import org.apache.sis.util.logging.Logging; import org.apache.sis.math.DecimalFunctions; import org.apache.sis.measure.NumberRange; +import org.apache.sis.coverage.SampleDimension; import static org.apache.sis.internal.coverage.j2d.ImageUtilities.LOGGER; @@ -81,7 +84,7 @@ class BandedSampleConverter extends ComputedImage { * * @see #getPropertyNames() */ - private static final String[] ADDED_PROPERTIES = {SAMPLE_RESOLUTIONS_KEY}; + private static final String[] ADDED_PROPERTIES = {SAMPLE_DIMENSIONS_KEY, SAMPLE_RESOLUTIONS_KEY}; /** * The transfer functions to apply on each band of the source image. @@ -93,6 +96,16 @@ class BandedSampleConverter extends ComputedImage { */ private final ColorModel colorModel; + /** + * Description of bands, or {@code null} if unknown. + * Not used by this class, but provided as a {@value #SAMPLE_DIMENSIONS_KEY} property. + * The value is fetched from {@link SampleDimensions#CONVERTED_BANDS} for avoiding to + * expose a {@code SampleDimension[]} argument in public {@link ImageProcessor} API. + * + * @see #getProperty(String) + */ + private final SampleDimension[] sampleDimensions; + /** * The sample resolutions, or {@code null} if unknown. */ @@ -107,14 +120,17 @@ class BandedSampleConverter extends ComputedImage { * @param ranges the expected range of values for each band, or {@code null} if unknown. * @param converters the transfer functions to apply on each band of the source image. * If this array was a user-provided parameter, should be cloned by caller. + * @param sampleDimensions description of conversion result, or {@code null} if unknown. */ private BandedSampleConverter(final RenderedImage source, final BandedSampleModel sampleModel, final ColorModel colorModel, final NumberRange<?>[] ranges, - final MathTransform1D[] converters) + final MathTransform1D[] converters, + final SampleDimension[] sampleDimensions) { super(sampleModel, source); this.colorModel = colorModel; this.converters = converters; + this.sampleDimensions = sampleDimensions; /* * Get an estimation of the resolution, arbitrarily looking in the middle of the range of values. * If the converters are linear (which is the most common case), the middle value does not matter @@ -189,7 +205,7 @@ class BandedSampleConverter extends ComputedImage { * @param colorizer provider of color model for the expected range of values, or {@code null}. * @return the image which compute converted values from the given source. * - * @see ImageProcessor#convert(RenderedImage, NumberRange[], MathTransform1D[], DataType, ColorModel) + * @see ImageProcessor#convert(RenderedImage, NumberRange[], MathTransform1D[], DataType) */ static BandedSampleConverter create(RenderedImage source, final ImageLayout layout, final NumberRange<?>[] sourceRanges, final MathTransform1D[] converters, @@ -197,23 +213,40 @@ class BandedSampleConverter extends ComputedImage { { /* * Since this operation applies its own ColorModel anyway, skip operation that was doing nothing else - * than changing the color model. + * than changing the color model. The new color model may be specified by the user if (s)he provided + * a `Colorizer` instance. Otherwise a default color model will be inferred. */ if (source instanceof RecoloredImage) { source = ((RecoloredImage) source).source; } final int numBands = converters.length; final BandedSampleModel sampleModel = layout.createBandedSampleModel(targetType, numBands, source, null); + final SampleDimension[] sampleDimensions = SampleDimensions.CONVERTED_BANDS.get(); final int visibleBand = ImageUtilities.getVisibleBand(source); - ColorModel colorModel = null; + ColorModel colorModel = ColorModelBuilder.NULL_COLOR_MODEL; if (colorizer != null) { - colorModel = colorizer.apply(new Colorizer.Target(sampleModel, null, visibleBand)).orElse(null); + var target = new Colorizer.Target(sampleModel, UnmodifiableArrayList.wrap(sampleDimensions), visibleBand); + colorModel = colorizer.apply(target).orElse(null); } if (colorModel == null) { - colorModel = ColorModelFactory.createGrayScale(sampleModel, visibleBand, null); + /* + * If no color model was specified or inferred from a colorizer, + * default to grayscale for a range inferred from the sample dimension. + * If no sample dimension is specified, infer value range from data type. + */ + SampleDimension sd = null; + if (sampleDimensions != null && visibleBand >= 0 && visibleBand < sampleDimensions.length) { + sd = sampleDimensions[visibleBand]; + } + final var builder = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE); + if (builder.initialize(source.getSampleModel(), sd) || + builder.initialize(source.getColorModel())) + { + colorModel = builder.createColorModel(targetType, numBands, Math.max(visibleBand, 0)); + } } /* - * If the source image is writable, then changes in the converted image may be retro-propagated + * If the source image is writable, then change in the converted image may be retro-propagated * to that source image. If we fail to compute the required inverse transforms, log a notice at * a low level because this is not a serious problem; writable BandedSampleConverter is a plus * but not a requirement. @@ -223,25 +256,42 @@ class BandedSampleConverter extends ComputedImage { for (int i=0; i<numBands; i++) { inverses[i] = converters[i].inverse(); } - return new Writable((WritableRenderedImage) source, sampleModel, colorModel, sourceRanges, converters, inverses); + return new Writable((WritableRenderedImage) source, sampleModel, colorModel, sourceRanges, converters, inverses, sampleDimensions); } catch (NoninvertibleTransformException e) { Logging.recoverableException(LOGGER, ImageProcessor.class, "convert", e); } - return new BandedSampleConverter(source, sampleModel, colorModel, sourceRanges, converters); + return new BandedSampleConverter(source, sampleModel, colorModel, sourceRanges, converters, sampleDimensions); } /** * Gets a property from this image. Current implementation recognizes: - * {@value #SAMPLE_RESOLUTIONS_KEY}. + * <ul> + * <li>{@value #SAMPLE_RESOLUTIONS_KEY}, computed by this class.</li> + * <li>{@value #SAMPLE_DIMENSIONS_KEY}, provided to the constructor.</li> + * <li>All positional properties, forwarded to source image.</li> + * </ul> */ @Override public Object getProperty(final String key) { - if (SAMPLE_RESOLUTIONS_KEY.equals(key)) { - if (sampleResolutions != null) { - return sampleResolutions.clone(); + switch (key) { + case SAMPLE_DIMENSIONS_KEY: { + if (sampleDimensions != null) { + return sampleDimensions.clone(); + } + break; + } + case SAMPLE_RESOLUTIONS_KEY: { + if (sampleResolutions != null) { + return sampleResolutions.clone(); + } + break; + } + default: { + if (SourceAlignedImage.POSITIONAL_PROPERTIES.contains(key)) { + return getSource().getProperty(key); + } + break; } - } else if (SourceAlignedImage.POSITIONAL_PROPERTIES.contains(key)) { - return getSource().getProperty(key); } return super.getProperty(key); } @@ -394,9 +444,10 @@ class BandedSampleConverter extends ComputedImage { */ Writable(final WritableRenderedImage source, final BandedSampleModel sampleModel, final ColorModel colorModel, final NumberRange<?>[] ranges, - final MathTransform1D[] converters, final MathTransform1D[] inverses) + final MathTransform1D[] converters, final MathTransform1D[] inverses, + final SampleDimension[] sampleDimensions) { - super(source, sampleModel, colorModel, ranges, converters); + super(source, sampleModel, colorModel, ranges, converters, sampleDimensions); this.inverses = inverses; } diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java b/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java index 330e7170e4..d21f3d2895 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java +++ b/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java @@ -118,9 +118,12 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode /** * Returns a description of the bands of the image to colorize. - * This is typically obtained by {@link org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}. + * This information may be present if the image operation is invoked by a + * {@link org.apache.sis.coverage.grid.GridCoverageProcessor} operation, + * or if the source image contains the {@value PlanarImage#SAMPLE_DIMENSIONS_KEY} property * * @return description of the bands of the image to colorize. + * @see org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions() */ public Optional<List<SampleDimension>> getRanges() { return Optional.ofNullable(ranges); @@ -132,6 +135,7 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode * This information is ignored if the colorization uses many bands (e.g. {@link #ARGB}). * * @return the band to colorize if the colorization algorithm uses only one band. + * @see org.apache.sis.coverage.grid.ImageRenderer#setVisibleBand(int) */ public OptionalInt getVisibleBand() { return (visibleBand >= 0) ? OptionalInt.of(visibleBand) : OptionalInt.empty(); @@ -188,7 +192,7 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode * @param colors the colors to use for the specified range of sample values. * @return a colorizer which will interpolate the given colors in the given range of values. * - * @see ImageProcessor#visualize(RenderedImage, List) + * @see ImageProcessor#visualize(RenderedImage) */ public static Colorizer forRanges(final Map<NumberRange<?>,Color[]> colors) { ArgumentChecks.ensureNonEmpty("colors", colors.entrySet()); @@ -209,19 +213,26 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode } /** - * Creates a colorizer which will associate colors to coverage categories. + * Creates a colorizer which will interpolate colors in ranges identified by categories. + * This colorizer is similar to {@link #forRanges(Map)} (with the same limitations) except that instead of mapping + * colors to predefined ranges of pixel values, it maps colors to {@linkplain Category#getName() category names}, + * {@linkplain org.apache.sis.measure.MeasurementRange#unit() units of measurement} or other properties. * The given function provides a way to colorize images without knowing in advance the numerical values of pixels. * For example, instead of specifying <cite>"pixel value 0 is blue, 1 is green, 2 is yellow"</cite>, * the given function allows to specify <cite>"Lakes are blue, Forests are green, Sand is yellow"</cite>. + * The function can return {@code null} or empty color arrays for some categories, + * which are interpreted as fully transparent pixels. * * <p>This colorizer is used when {@link Target#getRanges()} provides a non-empty value. - * The given function can return {@code null} or empty arrays for some categories, - * which are interpreted as fully transparent pixels.</p> + * That value is typically fetched from the {@value PlanarImage#SAMPLE_DIMENSIONS_KEY} image property, + * which is itself typically fetched from {@link org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}. + * If no sample dimension information is available, then this colorizer do not build a color model. + * A fallback can be specified with {@link #orElse(Colorizer)}.</p> * * @param colors colors to use for arbitrary categories of sample values. * @return a colorizer which will apply colors determined by the {@link Category} of sample values. * - * @see ImageProcessor#visualize(RenderedImage, List) + * @see ImageProcessor#visualize(RenderedImage) */ public static Colorizer forCategories(final Function<Category,Color[]> colors) { ArgumentChecks.ensureNonNull("colors", colors); @@ -235,8 +246,9 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode if (visibleBand < ranges.size()) { final SampleModel model = target.getSampleModel(); final var c = new ColorModelBuilder(colors); - c.initialize(model, ranges.get(visibleBand)); - return Optional.ofNullable(c.createColorModel(model.getDataType(), model.getNumBands(), visibleBand)); + if (c.initialize(model, ranges.get(visibleBand))) { + return Optional.ofNullable(c.createColorModel(model.getDataType(), model.getNumBands(), visibleBand)); + } } } } diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ImageAdapter.java b/core/sis-feature/src/main/java/org/apache/sis/image/ImageAdapter.java index 932afd66a6..0d0dd534bb 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageAdapter.java +++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageAdapter.java @@ -34,12 +34,12 @@ import org.apache.sis.util.Disposable; * indices), and all methods fetching tiles, delegate to the wrapped image. * * <h2>Design note</h2> - * most non-abstract methods are final because {@link PixelIterator} (among others) relies + * Most non-abstract methods are final because {@link PixelIterator} (among others) relies * on the fact that it can unwrap this image and still get the same pixel values. * * <h2>Relationship with other classes</h2> * This class is similar to {@link SourceAlignedImage} except that it does not extend {@link ComputedImage} - * and forward {@link #getTile(int, int)}, {@link #getData()} and other data methods to the source image. + * and forwards {@link #getTile(int, int)}, {@link #getData()} and other data methods to the source image. * * <h2>Requirements for subclasses</h2> * All subclasses shall override {@link #equals(Object)} and {@link #hashCode()}. @@ -80,7 +80,7 @@ abstract class ImageAdapter extends PlanarImage { /** * Returns the names of properties of wrapped image. * - * @return all recognized property names. + * @return all recognized property names, or {@code null} if none. */ @Override public String[] getPropertyNames() { diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java index f1f30fd2d4..e0ca82b6cc 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java +++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java @@ -618,7 +618,7 @@ public class ImageProcessor implements Cloneable { * * @since 1.2 */ - public DoubleUnaryOperator filterNodataValues(final Number... values) { + public static DoubleUnaryOperator filterNodataValues(final Number... values) { return (values != null) ? StatisticsCalculator.filterNodataValues(values) : null; } @@ -820,10 +820,13 @@ public class ImageProcessor implements Cloneable { * </tr><tr> * <td>{@code "sampleDimensions"}</td> * <td>Meaning of pixel values.</td> - * <td>{@link SampleDimension}</td> + * <td>{@link SampleDimension} or {@code SampleDimension[]}</td> * </tr> * </table> * + * <b>Note:</b> if no value is associated to the {@code "sampleDimensions"} key, then the default + * value will be the {@value PlanarImage#SAMPLE_DIMENSIONS_KEY} image property value if defined. + * * <h4>Properties used</h4> * This operation uses the following properties in addition to method parameters: * <ul> @@ -846,6 +849,44 @@ public class ImageProcessor implements Cloneable { return RecoloredImage.stretchColorRamp(this, source, modifiers); } + /** + * Returns an image augmented with user-defined property values. + * The specified properties overwrite any property that may be defined by the source image. + * When an {@linkplain RenderedImage#getProperty(String) image property value is requested}, the steps are: + * + * <ol> + * <li>If the {@code properties} map has an entry for the property name, returns the associated value. + * It may be {@code null}.</li> + * <li>Otherwise if the property is defined by the source image, returns its value. + * It may be {@code null}.</li> + * <li>Otherwise returns {@link java.awt.Image#UndefinedProperty}.</li> + * </ol> + * + * The given {@code properties} map is retained by reference in the returned image. + * The {@code Map} is <em>not</em> copied in order to allow + * the use of custom implementations doing deferred calculations. + * If the caller intends to modify the map content after this method call, + * (s)he should use a {@link java.util.concurrent.ConcurrentMap}. + * + * <p>The returned image is "live": changes in {@code source} image properties or in + * {@code properties} map entries are immediately reflected in the returned image.</p> + * + * <p>Null are valid image property values. An entry associated with the {@code null} + * value in the {@code properties} map is not the same as an absence of entry.</p> + * + * @param source the source image to augment with user-specified property values. + * @param properties properties overwriting or completing {@code source} properties. + * @return an image augmented with the specified properties. + * + * @see RenderedImage#getPropertyNames() + * @see RenderedImage#getProperty(String) + * + * @since 1.4 + */ + public RenderedImage addUserProperties(final RenderedImage source, final Map<String,Object> properties) { + return unique(new UserProperties(source, properties)); + } + /** * Selects a subset of bands in the given image. This method can also be used for changing band order * or repeating the same band from the source image. If the specified {@code bands} are the same than @@ -937,7 +978,7 @@ public class ImageProcessor implements Cloneable { * * @since 1.4 */ - public RenderedImage aggregateBands(RenderedImage[] sources, int[][] bandsPerSource) { + public RenderedImage aggregateBands(final RenderedImage[] sources, final int[][] bandsPerSource) { ArgumentChecks.ensureNonEmpty("sources", sources); final Colorizer colorizer; synchronized (this) { @@ -1214,8 +1255,7 @@ public class ImageProcessor implements Cloneable { * * @param source the image to recolor for visualization purposes. * @param colors colors to use for each range of values in the source image. - * @deprecated Replaced by {@link #visualize(RenderedImage, List)} with {@code null} list argument - * and colors map inferred from the {@link Colorizer}. + * @deprecated Replaced by {@link #visualize(RenderedImage)} with colors map inferred from the {@link Colorizer}. */ @Deprecated(since="1.4", forRemoval=true) public synchronized RenderedImage visualize(final RenderedImage source, final Map<NumberRange<?>,Color[]> colors) { @@ -1234,6 +1274,19 @@ public class ImageProcessor implements Cloneable { } } + /** + * @deprecated Replaced by {@link #visualize(RenderedImage)} with sample dimensions + * read from the {@value PlanarImage#SAMPLE_DIMENSIONS_KEY} property. + * + * @param ranges description of {@code source} bands, or {@code null} if none. This is typically + * obtained by {@link org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}. + */ + @Deprecated(since="1.4", forRemoval=true) + public RenderedImage visualize(final RenderedImage source, final List<SampleDimension> ranges) { + ArgumentChecks.ensureNonNull("source", source); + return visualize(new Visualization.Builder(null, source, null, ranges)); + } + /** * Returns an image where all sample values are indices of colors in an {@link IndexColorModel}. * If the given image stores sample values as unsigned bytes or short integers, then those values @@ -1244,7 +1297,26 @@ public class ImageProcessor implements Cloneable { * There is no guarantee about the number of bands in returned image or about which formula is used for converting * floating point values to integer values.</p> * - * <h4>Specifying colors for ranges of pixel values</h4> + * <h4>How to specify colors</h4> + * The image colors can be controlled by the {@link Colorizer} set on this image processor. + * It is possible to {@linkplain Colorizer#forInstance(ColorModel) specify explicitely} the + * {@link ColorModel} to use, but this approach is unsafe because it depends on the pixel values + * <em>after</em> their conversion to the visualization image, which is implementation dependent. + * A safer approach is to define colors relative to pixel values <em>before</em> their conversions. + * It can be done in two ways, depending on whether the {@value PlanarImage#SAMPLE_DIMENSIONS_KEY} + * image property is defined or not. + * Those two ways are described in next sections and can be combined in a chain of fallbacks. + * For example the following colorizer will choose colors based on sample dimensions if available, + * or fallback on predefined ranges of pixel values otherwise: + * + * {@snippet lang="java" : + * Function<Category,Color[]> flexible = ...; + * Map<NumberRange<?>,Color[]> predefined = ...; + * processor.setColorizer(Colorizer.forCategories(flexible) // Preferred way. + * .orElse(Colorizer.forRanges(predefined))); // Fallback. + * } + * + * <h5>Specifying colors for ranges of pixel values</h5> * When no {@link SampleDimension} information is available, the recommended way to specify colors is like below. * In this example, <var>min</var> and <var>max</var> are minimum and maximum values * (inclusive in this example, but they could be exclusive as well) in the <em>source</em> image. @@ -1263,7 +1335,7 @@ public class ImageProcessor implements Cloneable { * The {@link Color} arrays may have any length; colors will be interpolated as needed for fitting * the ranges of values in the destination image. * - * <h4>Specifying colors for sample dimension categories</h4> + * <h5>Specifying colors for sample dimension categories</h5> * If {@link SampleDimension} information is available, a more flexible way to specify colors * is to associate colors to category names instead of predetermined ranges of pixel values. * The ranges will be inferred indirectly, {@linkplain Category#getSampleRange() from the categories} @@ -1287,15 +1359,6 @@ public class ImageProcessor implements Cloneable { * The {@link Color} arrays may have any length; colors will be interpolated as needed for fitting * the ranges of values in the destination image. * - * <p>The two approaches can be combined. For example the following colorizer will choose colors based - * on sample dimensions if available, or fallback on predefined ranges of pixel values otherwise:</p> - * - * {@snippet lang="java" : - * Function<Category,Color[]> flexible = ...; - * Map<NumberRange<?>,Color[]> predefined = ...; - * processor.setColorizer(Colorizer.forCategories(flexible).orElse(Colorizer.forRanges(predefined))); - * } - * * <h4>Properties used</h4> * This operation uses the following properties in addition to method parameters: * <ul> @@ -1303,16 +1366,17 @@ public class ImageProcessor implements Cloneable { * </ul> * * @param source the image to recolor for visualization purposes. - * @param ranges description of {@code source} bands, or {@code null} if none. This is typically - * obtained by {@link org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}. * @return recolored image for visualization purposes only. * * @see Colorizer#forRanges(Map) * @see Colorizer#forCategories(Function) + * @see PlanarImage#SAMPLE_DIMENSIONS_KEY + * + * @since 1.4 */ - public RenderedImage visualize(final RenderedImage source, final List<SampleDimension> ranges) { + public RenderedImage visualize(final RenderedImage source) { ArgumentChecks.ensureNonNull("source", source); - return visualize(new Visualization.Builder(null, source, null, ranges)); + return visualize(new Visualization.Builder(null, source, null)); } /** @@ -1322,7 +1386,7 @@ public class ImageProcessor implements Cloneable { * * <ol> * <li><code>{@linkplain #resample(RenderedImage, Rectangle, MathTransform) resample}(source, bounds, toSource)</code></li> - * <li><code>{@linkplain #visualize(RenderedImage, List) visualize}(resampled, ranges)</code></li> + * <li><code>{@linkplain #visualize(RenderedImage) visualize}(resampled)</code></li> * </ol> * * Combining above steps may be advantageous when the {@code resample(…)} result is not needed for anything @@ -1349,10 +1413,26 @@ public class ImageProcessor implements Cloneable { * @param bounds domain of pixel coordinates of resampled image to create. * Updated by this method if {@link Resizing#EXPAND} policy is applied. * @param toSource conversion of pixel coordinates from resampled image to {@code source} image. - * @param ranges description of {@code source} bands, or {@code null} if none. This is typically - * obtained by {@link org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}. * @return resampled and recolored image for visualization purposes only. + * + * @since 1.4 + */ + public RenderedImage visualize(final RenderedImage source, final Rectangle bounds, final MathTransform toSource) { + ArgumentChecks.ensureNonNull("source", source); + ArgumentChecks.ensureNonNull("bounds", bounds); + ArgumentChecks.ensureNonNull("toSource", toSource); + ensureNonEmpty(bounds); + return visualize(new Visualization.Builder(bounds, source, toSource)); + } + + /** + * @deprecated Replaced by {@link #visualize(RenderedImage, Rectangle, MathTransform)} with + * sample dimensions read from the {@value PlanarImage#SAMPLE_DIMENSIONS_KEY} property. + * + * @param ranges description of {@code source} bands, or {@code null} if none. This is typically + * obtained by {@link org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}. */ + @Deprecated(since="1.4", forRemoval=true) public RenderedImage visualize(final RenderedImage source, final Rectangle bounds, final MathTransform toSource, final List<SampleDimension> ranges) { diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java index 5fb2e07651..de86487842 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java +++ b/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java @@ -37,6 +37,7 @@ import org.apache.sis.internal.coverage.j2d.ImageUtilities; import org.apache.sis.internal.coverage.j2d.TileOpExecutor; import org.apache.sis.internal.coverage.j2d.ColorModelFactory; import org.apache.sis.coverage.grid.GridGeometry; // For javadoc +import org.apache.sis.coverage.SampleDimension; import static java.lang.Math.multiplyFull; @@ -143,6 +144,17 @@ public abstract class PlanarImage implements RenderedImage { */ public static final String POSITIONAL_ACCURACY_KEY = "org.apache.sis.PositionalAccuracy"; + /** + * Key for a property defining a conversion from pixel values to the units of measurement. + * The value should be an array of {@link SampleDimension} instances. + * The array length should be the number of bands. + * + * @see org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions() + * + * @since 1.4 + */ + public static final String SAMPLE_DIMENSIONS_KEY = "org.apache.sis.SampleDimensions"; + /** * Key of a property defining the resolutions of sample values in each band. This property is recommended * for images having sample values as floating point numbers. For example if sample values were computed by @@ -155,7 +167,7 @@ public abstract class PlanarImage implements RenderedImage { * {@linkplain org.apache.sis.coverage.grid.GridCoverage#forConvertedValues(boolean) conversions from * integer values to floating point values}.</p> */ - public static final String SAMPLE_RESOLUTIONS_KEY = "org.apache.sis.SampleResolution"; + public static final String SAMPLE_RESOLUTIONS_KEY = "org.apache.sis.SampleResolutions"; /** * Key of property providing statistics on sample values in each band. Providing a value for this key @@ -231,6 +243,9 @@ public abstract class PlanarImage implements RenderedImage { * <td>{@value #POSITIONAL_ACCURACY_KEY}</td> * <td>Estimation of positional accuracy, typically in metres or pixel units.</td> * </tr><tr> + * <td>{@value #SAMPLE_DIMENSIONS_KEY}</td> + * <td>Conversions from pixel values to the units of measurement for each band.</td> + * </tr><tr> * <td>{@value #SAMPLE_RESOLUTIONS_KEY}</td> * <td>Resolutions of sample values in each band.</td> * </tr><tr> diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java index 42d535f1d2..ce24f13234 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java +++ b/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java @@ -44,7 +44,7 @@ import org.apache.sis.measure.NumberRange; * for {@link ImageProcessor}, defined here for reducing {@link ImageProcessor} size. * * @author Martin Desruisseaux (Geomatys) - * @version 1.2 + * @version 1.4 * @since 1.1 */ final class RecoloredImage extends ImageAdapter { @@ -226,18 +226,9 @@ final class RecoloredImage extends ImageAdapter { } value = modifiers.get("sampleDimensions"); if (value != null) { - if (value instanceof List<?>) { - final List<?> ranges = (List<?>) value; - if (visibleBand < ranges.size()) { - value = ranges.get(visibleBand); - } - } - if (value != null) { - if (value instanceof SampleDimension) { - range = (SampleDimension) value; - } else { - throw illegalPropertyType(modifiers, "sampleDimensions", value); - } + range = getSampleDimension(value, visibleBand); + if (range == null) { + throw illegalPropertyType(modifiers, "sampleDimensions", value); } } } @@ -249,7 +240,7 @@ final class RecoloredImage extends ImageAdapter { if (statistics == null) { if (statsAllBands == null) { final DoubleUnaryOperator[] sampleFilters = new DoubleUnaryOperator[visibleBand + 1]; - sampleFilters[visibleBand] = processor.filterNodataValues(nodataValues); + sampleFilters[visibleBand] = ImageProcessor.filterNodataValues(nodataValues); statsAllBands = processor.valueOfStatistics(statsSource, areaOfInterest, sampleFilters); } if (statsAllBands != null && visibleBand < statsAllBands.length) { @@ -282,6 +273,9 @@ final class RecoloredImage extends ImageAdapter { final int size = icm.getMapSize(); int validMin = 0; int validMax = size - 1; // Inclusive. + if (range == null) { + range = getSampleDimension(source.getProperty(PlanarImage.SAMPLE_DIMENSIONS_KEY), visibleBand); + } if (range != null) { double span = 0; for (final Category category : range.getCategories()) { @@ -345,6 +339,28 @@ final class RecoloredImage extends ImageAdapter { return ImageProcessor.unique(new RecoloredImage(source, cm, minimum, maximum)); } + /** + * Gets the sample dimension from the given property value. + * + * @param value the property value. + * @param visibleBand index of the element to fetch if the property is a list or an array. + * @return the sample dimension at the given visible band index, or {@code null} if none. + */ + private static SampleDimension getSampleDimension(Object value, final int visibleBand) { + if (value instanceof SampleDimension[]) { + final var ranges = (SampleDimension[]) value; + if (visibleBand < ranges.length) { + return ranges[visibleBand]; + } + } else if (value instanceof List<?>) { + final var ranges = (List<?>) value; + if (visibleBand < ranges.size()) { + value = ranges.get(visibleBand); + } + } + return (value instanceof SampleDimension) ? (SampleDimension) value : null; + } + /** * Returns the exception to be thrown when a property is of illegal type. */ diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ResampledImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/ResampledImage.java index f9cc28d46f..0aa72e1f55 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/image/ResampledImage.java +++ b/core/sis-feature/src/main/java/org/apache/sis/image/ResampledImage.java @@ -479,24 +479,25 @@ public class ResampledImage extends ComputedImage { /** * Gets a property from this image. Current default implementation supports the following keys - * (more properties may be added to this list in future Apache SIS versions): + * (more properties may be added to this list in any future Apache SIS versions): * * <ul> * <li>{@value #POSITIONAL_ACCURACY_KEY}</li> * <li>{@value #POSITIONAL_CONSISTENCY_KEY}</li> + * <li>{@value #SAMPLE_DIMENSIONS_KEY} (forwarded to the source image)</li> * <li>{@value #SAMPLE_RESOLUTIONS_KEY} (forwarded to the source image)</li> * <li>{@value #MASK_KEY} if the image uses floating point numbers.</li> * </ul> * - * <div class="note"><b>Note:</b> - * the sample resolutions are retained because they should have approximately the same values before and after + * <h4>Note on sample values</h4> + * The sample resolutions are retained because they should have approximately the same values before and after * resampling. {@linkplain #STATISTICS_KEY Statistics} are not in this list because, while minimum and maximum * values should stay approximately the same, the average value and standard deviation may be quite different. - * </div> */ @Override public Object getProperty(final String key) { switch (key) { + case SAMPLE_DIMENSIONS_KEY: case SAMPLE_RESOLUTIONS_KEY: { return getSource().getProperty(key); } @@ -527,6 +528,7 @@ public class ResampledImage extends ComputedImage { public String[] getPropertyNames() { final String[] inherited = getSource().getPropertyNames(); final String[] names = { + SAMPLE_DIMENSIONS_KEY, SAMPLE_RESOLUTIONS_KEY, POSITIONAL_ACCURACY_KEY, POSITIONAL_CONSISTENCY_KEY, diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/UserProperties.java b/core/sis-feature/src/main/java/org/apache/sis/image/UserProperties.java new file mode 100644 index 0000000000..a26f9718de --- /dev/null +++ b/core/sis-feature/src/main/java/org/apache/sis/image/UserProperties.java @@ -0,0 +1,124 @@ +/* + * 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.image; + +import java.util.Arrays; +import java.util.Map; +import java.util.Set; +import java.util.HashSet; +import java.awt.Image; +import java.awt.image.RenderedImage; +import org.apache.sis.util.ArgumentChecks; + + +/** + * An image with some properties overwritten by user-specified properties. + * The property calculations may be deferred, and {@code null} is a valid property value. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.4 + * @since 1.4 + */ +final class UserProperties extends ImageAdapter { + /** + * The user-specified properties which may overwrite source image properties. + * This is a reference to the map specified at construction time, not a copy. + * No copy is done for allowing the use of instances doing deferred computation. + * It is legal to have {@code null} value associated to keys: the meaning is not + * the same as "undefined properties". + * + * <p>This {@code UserProperties} class shall not modify the content of this map.</p> + */ + private final Map<String,Object> properties; + + /** + * Creates a new wrapper for the given image. + * + * @param source the image to wrap. The map is retained directly (not cloned). + */ + @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter") + UserProperties(final RenderedImage source, final Map<String,Object> properties) { + super(source); + ArgumentChecks.ensureNonNull("properties", properties); + this.properties = properties; + } + + /** + * Returns the names of all supported properties, including in wrapped image. + * The property names are computed on each invocation for allowing dynamic changes + * in source image properties or in {@link #properties} map. + * + * @return all recognized property names, or {@code null} if none. + */ + @Override + public String[] getPropertyNames() { + String[] names = super.getPropertyNames(); + if (!properties.isEmpty()) { + final Set<String> union; + if (names != null) { + union = new HashSet<>(Arrays.asList(names)); + union.addAll(properties.keySet()); + } else { + union = properties.keySet(); + } + names = union.toArray(String[]::new); + } + return (names.length != 0) ? names : null; + } + + /** + * Gets a property from this image or from its source. + * + * @param name name of the property to get. + * @return the property for the given name ({@code null} is a valid result), + * or {@link Image#UndefinedProperty} if the given name is not a recognized property name. + */ + @Override + public Object getProperty(final String name) { + Object value = properties.getOrDefault(name, Image.UndefinedProperty); + if (value == Image.UndefinedProperty) { + value = super.getProperty(name); + } + return value; + } + + /** + * Compares the given object with this image for equality. This method should be quick and compare + * how images compute their values from their sources; it should not compare the actual pixel values. + */ + @Override + public boolean equals(final Object object) { + return super.equals(object) && properties.equals(((UserProperties) object).properties); + } + + /** + * Returns a hash code value for this image. This method should be quick. + */ + @Override + public int hashCode() { + return super.hashCode() + 71 * properties.hashCode(); + } + + /** + * Appends a content to show in the {@link #toString()} representation. + */ + @Override + Class<? extends ImageAdapter> appendStringContent(final StringBuilder buffer) { + buffer.append(properties.keySet()); + return UserProperties.class; + } +} diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java index 4b8f38804f..35229898d1 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java +++ b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java @@ -41,7 +41,6 @@ import org.apache.sis.referencing.operation.transform.MathTransforms; import org.apache.sis.internal.coverage.SampleDimensions; import org.apache.sis.internal.coverage.CompoundTransform; import org.apache.sis.internal.coverage.j2d.ColorModelBuilder; -import org.apache.sis.internal.coverage.j2d.ColorModelFactory; import org.apache.sis.internal.coverage.j2d.ImageLayout; import org.apache.sis.internal.coverage.j2d.ImageUtilities; import org.apache.sis.internal.feature.Resources; @@ -118,7 +117,7 @@ final class Visualization extends ResampledImage { * * <p>This builder accepts two kinds of input:</p> * <ul> - * <li>Non-null {@link #sourceBands} and {@link Target#categoryColors}.</li> + * <li>Non-null {@link #sampleDimensions} and {@link Target#categoryColors}.</li> * <li>Non-null {@link Target#rangeColors}.</li> * </ul> * @@ -136,8 +135,8 @@ final class Visualization extends ResampledImage { /** Number of bands of the image to create. */ private static final int NUM_BANDS = 1; - /** Band to make visible. */ - private static final int VISIBLE_BAND = ColorModelFactory.DEFAULT_VISIBLE_BAND; + /** Band to make visible among the remaining {@value #NUM_BANDS} bands. */ + private static final int VISIBLE_BAND = 0; //// ┌─────────────────────────────────────┐ //// │ Arguments given by user │ @@ -153,7 +152,7 @@ final class Visualization extends ResampledImage { private MathTransform toSource; /** Description of {@link #source} bands, or {@code null} if none. */ - private List<SampleDimension> sourceBands; + private SampleDimension[] sampleDimensions; //// ┌─────────────────────────────────────┐ //// │ Given by ImageProcesor.configure(…) │ @@ -190,18 +189,30 @@ final class Visualization extends ResampledImage { /** * Creates a builder for a visualization image with colors inferred from sample dimensions. * - * @param bounds desired domain of pixel coordinates, or {@code null} if same as {@code source} image. - * @param source the image for which to replace the color model. - * @param toSource pixel coordinates conversion to {@code source} image, or {@code null} if none. - * @param sourceBands description of {@code source} bands. + * @param bounds desired domain of pixel coordinates, or {@code null} if same as {@code source} image. + * @param source the image for which to replace the color model. + * @param toSource pixel coordinates conversion to {@code source} image, or {@code null} if none. */ + Builder(final Rectangle bounds, final RenderedImage source, final MathTransform toSource) { + this.bounds = bounds; + this.source = source; + this.toSource = toSource; + Object ranges = source.getProperty(SAMPLE_DIMENSIONS_KEY); + if (ranges instanceof SampleDimension[]) { + sampleDimensions = (SampleDimension[]) ranges; + } + } + + @Deprecated(since="1.4", forRemoval=true) Builder(final Rectangle bounds, final RenderedImage source, final MathTransform toSource, - final List<SampleDimension> sourceBands) + final List<SampleDimension> sampleDimensions) { - this.bounds = bounds; - this.source = source; - this.toSource = toSource; - this.sourceBands = sourceBands; + this.bounds = bounds; + this.source = source; + this.toSource = toSource; + if (sampleDimensions != null) { + this.sampleDimensions = sampleDimensions.toArray(SampleDimension[]::new); + } } /** @@ -232,13 +243,16 @@ final class Visualization extends ResampledImage { } /* * Skip any previous `RecoloredImage` since we will replace the `ColorModel` by a new one. + * Discards image properties such as statistics because this image is not for computation. * Keep only the band to make visible in order to reduce the amount of calculation during * resampling and for saving memory. */ - while (source instanceof RecoloredImage) { - source = ((RecoloredImage) source).source; + while (source instanceof ImageAdapter) { + source = ((ImageAdapter) source).source; } source = BandSelectImage.create(source, new int[] {visibleBand}); + final SampleDimension visibleSD = (sampleDimensions != null && visibleBand < sampleDimensions.length) + ? sampleDimensions[visibleBand] : null; /* * If there is no conversion of pixel coordinates, there is no need for interpolations. * In such case the `Visualization.computeTile(…)` implementation takes a shortcut which @@ -258,7 +272,7 @@ final class Visualization extends ResampledImage { * which must be done before to build the color model. */ sampleModel = layout.createBandedSampleModel(ColorModelBuilder.TYPE_COMPACT, NUM_BANDS, source, bounds); - final Target target = new Target(sampleModel, visibleBand, sourceBands != null); + final Target target = new Target(sampleModel, VISIBLE_BAND, visibleSD != null); if (colorizer != null) { colorModel = colorizer.apply(target).orElse(null); } @@ -267,8 +281,8 @@ final class Visualization extends ResampledImage { * There is different ways to setup the builder, depending on which `Colorizer` is used. * In precedence order: * - * - rangeColors : Map<NumberRange<?>,Color[]> - * - sourceBands : List<SampleDimension> + * - rangeColors : Map<NumberRange<?>,Color[]> + * - sampleDimensions : SampleDimension[] * - statistics */ boolean initialized; @@ -282,7 +296,7 @@ final class Visualization extends ResampledImage { * in various ways: sample dimensions, scaled color model, or image statistics in last resort. */ builder = new ColorModelBuilder(target.categoryColors); - initialized = (sourceBands != null) && builder.initialize(coloredSource.getSampleModel(), sourceBands.get(visibleBand)); + initialized = builder.initialize(coloredSource.getSampleModel(), visibleSD); if (initialized) { /* * If we have been able to configure ColorModelBuilder using SampleDimension, apply an adjustment @@ -314,7 +328,7 @@ final class Visualization extends ResampledImage { * If none of above `ColorModelBuilder` configurations worked, use statistics in last resort. * We do that after we reduced the image to a single band in order to reduce the amount of calculations. */ - final DoubleUnaryOperator[] sampleFilters = SampleDimensions.toSampleFilters(processor, sourceBands); + final DoubleUnaryOperator[] sampleFilters = SampleDimensions.toSampleFilters(visibleSD); final Statistics statistics = processor.valueOfStatistics(source, null, sampleFilters)[VISIBLE_BAND]; builder.initialize(statistics.minimum(), statistics.maximum()); } diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java index 349c4e6d6d..d896643025 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java +++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java @@ -19,6 +19,8 @@ package org.apache.sis.internal.coverage; import java.util.List; import java.util.Optional; import java.util.function.DoubleUnaryOperator; +import java.awt.Shape; +import java.awt.image.RenderedImage; import org.apache.sis.coverage.SampleDimension; import org.apache.sis.coverage.Category; import org.apache.sis.image.ImageProcessor; @@ -30,10 +32,20 @@ import org.apache.sis.util.Static; * Utility methods working on {@link SampleDimension} instances. * * @author Martin Desruisseaux (Geomatys) - * @version 1.2 + * @version 1.4 * @since 1.2 */ public final class SampleDimensions extends Static { + /** + * The sample dimensions of a {@link org.apache.sis.image.BandedSampleConverter} image. + * We use this thread-local variable as an internal workaround for an parameter that we + * do not expose in the public API of {@link ImageProcessor}. + * + * <p>The content of the array in this thread-local variable shall not be modified, + * because it may be a direct reference to an internal array (not a clone).</p> + */ + public static final ThreadLocal<SampleDimension[]> CONVERTED_BANDS = new ThreadLocal<>(); + /** * Do not allow instantiation of this class. */ @@ -49,13 +61,13 @@ public final class SampleDimensions extends Static { * @return the background values, or {@code null} if the given argument was null. * Otherwise the returned array is never null but may contain null elements. */ - public static Number[] backgrounds(final List<SampleDimension> bands) { + public static Number[] backgrounds(final SampleDimension... bands) { if (bands == null) { return null; } - final Number[] fillValues = new Number[bands.size()]; + final Number[] fillValues = new Number[bands.length]; for (int i=fillValues.length; --i >= 0;) { - final SampleDimension band = bands.get(i); + final SampleDimension band = bands[i]; final Optional<Number> bg = band.getBackground(); if (bg.isPresent()) { fillValues[i] = bg.get(); @@ -66,25 +78,26 @@ public final class SampleDimensions extends Static { /** * Returns the {@code sampleFilters} arguments to use in a call to - * {@link ImageProcessor#statistics ImageProcessor.statistics(…)} for excluding no-data values. + * {@code ImageProcessor.statistics(…)} for excluding no-data values. * If the given sample dimensions are {@linkplain SampleDimension#converted() converted to units of measurement}, * then all "no data" values are already NaN values and this method returns an array of {@code null} operators. - * Otherwise this method returns an array of operators that covert "no data" values to {@link Double#NaN}. + * Otherwise this method returns an array of operators that convert "no data" values to {@link Double#NaN}. * * <p>This method is not in public API because it partially duplicates the work * of {@linkplain SampleDimension#getTransferFunction() transfer function}.</p> * - * @param processor the processor to use for creating {@link DoubleUnaryOperator}. - * @param bands the sample dimensions for which to create {@code sampleFilters}, or {@code null}. + * @param bands the sample dimensions for which to create {@code sampleFilters}, or {@code null}. * @return the filters, or {@code null} if {@code bands} was null. The array may contain null elements. + * + * @see ImageProcessor#statistics(RenderedImage, Shape, DoubleUnaryOperator...) */ - public static DoubleUnaryOperator[] toSampleFilters(final ImageProcessor processor, final List<SampleDimension> bands) { + public static DoubleUnaryOperator[] toSampleFilters(final SampleDimension... bands) { if (bands == null) { return null; } - final DoubleUnaryOperator[] sampleFilters = new DoubleUnaryOperator[bands.size()]; + final DoubleUnaryOperator[] sampleFilters = new DoubleUnaryOperator[bands.length]; for (int i = 0; i < sampleFilters.length; i++) { - final SampleDimension band = bands.get(i); + final SampleDimension band = bands[i]; if (band != null) { final List<Category> categories = band.getCategories(); final Number[] nodataValues = new Number[categories.size()]; @@ -103,7 +116,7 @@ public final class SampleDimensions extends Static { nodataValues[j] = value; } } - sampleFilters[i] = processor.filterNodataValues(nodataValues); + sampleFilters[i] = ImageProcessor.filterNodataValues(nodataValues); } } return sampleFilters; diff --git a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ConvertedGridCoverageTest.java b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ConvertedGridCoverageTest.java index 7c98795851..962168a6c1 100644 --- a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ConvertedGridCoverageTest.java +++ b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ConvertedGridCoverageTest.java @@ -18,6 +18,7 @@ package org.apache.sis.coverage.grid; import java.util.List; import java.awt.image.DataBuffer; +import java.awt.image.RenderedImage; import org.opengis.referencing.datum.PixelInCell; import org.opengis.referencing.operation.MathTransform1D; import org.apache.sis.referencing.operation.transform.MathTransforms; @@ -32,6 +33,7 @@ import org.junit.Test; import static org.apache.sis.test.FeatureAssert.*; import static org.apache.sis.test.TestUtilities.getSingleton; +import static org.apache.sis.image.PlanarImage.SAMPLE_DIMENSIONS_KEY; /** @@ -39,7 +41,7 @@ import static org.apache.sis.test.TestUtilities.getSingleton; * * @author Johann Sorel (Geomatys) * @author Martin Desruisseaux (Geomatys) - * @version 1.3 + * @version 1.4 * @since 1.1 */ public final class ConvertedGridCoverageTest extends TestCase { @@ -69,6 +71,20 @@ public final class ConvertedGridCoverageTest extends TestCase { return coverage; } + /** + * Creates a rendering of the given coverage and verifies that it contains + * a property for the sample dimensions. + */ + private static RenderedImage render(final GridCoverage coverage) { + final RenderedImage image = coverage.render(null); + final Object bands = image.getProperty(SAMPLE_DIMENSIONS_KEY); + assertInstanceOf(SAMPLE_DIMENSIONS_KEY, SampleDimension[].class, bands); + assertArrayEquals(SAMPLE_DIMENSIONS_KEY, + coverage.getSampleDimensions().toArray(SampleDimension[]::new), + (SampleDimension[]) bands); + return image; + } + /** * Tests forward conversion from packed values to "geophysics" values. * Test includes a conversion of an integer value to {@link Float#NaN}. @@ -79,12 +95,12 @@ public final class ConvertedGridCoverageTest extends TestCase { /* * Verify values before and after conversion. */ - assertValuesEqual(coverage.forConvertedValues(false).render(null), 0, new double[][] { + assertValuesEqual(render(coverage.forConvertedValues(false)), 0, new double[][] { {-1, 3} }); final float nan = MathFunctions.toNanFloat(-1); assertTrue(Float.isNaN(nan)); - assertValuesEqual(coverage.forConvertedValues(true).render(null), 0, new double[][] { + assertValuesEqual(render(coverage.forConvertedValues(true)), 0, new double[][] { {nan, 3} }); } @@ -101,7 +117,7 @@ public final class ConvertedGridCoverageTest extends TestCase { }, null); assertSame(target, target.forConvertedValues(true)); assertSame(source, target.forConvertedValues(false)); - assertValuesEqual(target.render(null), 0, new double[][] { + assertValuesEqual(render(target), 0, new double[][] { {90, 130} // {-1, 3} × 10 + 100 }); final SampleDimension band = getSingleton(target.getSampleDimensions()); diff --git a/core/sis-feature/src/test/java/org/apache/sis/image/ImageProcessorTest.java b/core/sis-feature/src/test/java/org/apache/sis/image/ImageProcessorTest.java index 4ad4b9f097..cf5c1a23bb 100644 --- a/core/sis-feature/src/test/java/org/apache/sis/image/ImageProcessorTest.java +++ b/core/sis-feature/src/test/java/org/apache/sis/image/ImageProcessorTest.java @@ -34,11 +34,38 @@ import static org.apache.sis.test.TestUtilities.getSingleton; * Tests {@link ImageProcessor}. * * @author Martin Desruisseaux (Geomatys) - * @version 1.1 + * @version 1.4 * @since 1.1 */ @DependsOn(org.apache.sis.internal.processing.isoline.IsolinesTest.class) public final class ImageProcessorTest extends TestCase { + /** + * The processor to test. + */ + private final ImageProcessor processor; + + /** + * Creates a new test case. + */ + public ImageProcessorTest() { + processor = new ImageProcessor(); + } + + /** + * Tests {@link ImageProcessor#addUserProperties(RenderedImage, Map)}. + */ + @Test + public void testAddUserProperties() { + final String key = "my-property"; + final String value = "my-value"; + final RenderedImage source = new BufferedImage(2, 2, BufferedImage.TYPE_BYTE_BINARY); + final RenderedImage image = processor.addUserProperties(source, Map.of(key, value)); + assertSame(BufferedImage.UndefinedProperty, source.getProperty(key)); + assertSame(BufferedImage.UndefinedProperty, image.getProperty("another-property")); + assertSame(value, image.getProperty(key)); + assertArrayEquals(new String[] {key}, image.getPropertyNames()); + } + /** * Tests {@link ImageProcessor#isolines(RenderedImage, double[][], MathTransform)}. */ @@ -46,8 +73,6 @@ public final class ImageProcessorTest extends TestCase { public void testIsolines() { final BufferedImage image = new BufferedImage(3, 3, BufferedImage.TYPE_BYTE_BINARY); image.getRaster().setSample(1, 1, 0, 1); - - final ImageProcessor processor = new ImageProcessor(); boolean parallel = false; do { processor.setExecutionMode(parallel ? ImageProcessor.Mode.SEQUENTIAL : ImageProcessor.Mode.PARALLEL); diff --git a/core/sis-feature/src/test/java/org/apache/sis/image/StatisticsCalculatorTest.java b/core/sis-feature/src/test/java/org/apache/sis/image/StatisticsCalculatorTest.java index ad822fa29d..5fa7d1c10a 100644 --- a/core/sis-feature/src/test/java/org/apache/sis/image/StatisticsCalculatorTest.java +++ b/core/sis-feature/src/test/java/org/apache/sis/image/StatisticsCalculatorTest.java @@ -161,7 +161,7 @@ public final class StatisticsCalculatorTest extends TestCase { public void testWithSampleFilters() { final ImageProcessor operations = new ImageProcessor(); sampleFilters = new DoubleUnaryOperator[] { - operations.filterNodataValues(100, 51324, 51323, 201, 310) + ImageProcessor.filterNodataValues(100, 51324, 51323, 201, 310) }; compareParallelWithSequential(operations, 101, 51322); } 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 2ba1782b1f..bea3a65a64 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 @@ -20,6 +20,7 @@ import java.util.Map; import java.util.List; import java.util.HashMap; import java.util.Objects; +import java.util.logging.Logger; import java.io.IOException; import java.io.UncheckedIOException; import java.awt.Graphics2D; @@ -29,7 +30,6 @@ import java.awt.image.RenderedImage; import java.awt.geom.Rectangle2D; import java.awt.geom.AffineTransform; import java.awt.geom.NoninvertibleTransformException; -import java.util.logging.Logger; import org.opengis.util.FactoryException; import org.opengis.geometry.DirectPosition; import org.opengis.metadata.extent.GeographicBoundingBox; @@ -194,7 +194,7 @@ public class RenderingData implements Cloneable { * @see #setImageSpace(GridGeometry, List, int[]) * @see #statistics() */ - private List<SampleDimension> dataRanges; + private SampleDimension[] dataRanges; /** * Conversion or transformation from {@linkplain #data} CRS to {@linkplain PlanarCanvas#getObjectiveCRS() @@ -306,10 +306,10 @@ public class RenderingData implements Cloneable { */ @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter") public final void setImageSpace(final GridGeometry domain, final List<SampleDimension> ranges, final int[] xyDims) { - processor.setFillValues(SampleDimensions.backgrounds(ranges)); - dataRanges = ranges; // Not cloned because already an unmodifiable list. + dataRanges = (ranges != null) ? ranges.toArray(SampleDimension[]::new) : null; dataGeometry = domain; xyDimensions = xyDims; + processor.setFillValues(SampleDimensions.backgrounds(dataRanges)); /* * If the grid geometry does not define a "grid to CRS" transform, set it to an identity transform. * We do that because this class needs a complete `GridGeometry` as much as possible. @@ -536,7 +536,7 @@ public class RenderingData implements Cloneable { image = coarse.render(sliceExtent); } } - statistics = processor.valueOfStatistics(image, null, SampleDimensions.toSampleFilters(processor, dataRanges)); + statistics = processor.valueOfStatistics(image, null, SampleDimensions.toSampleFilters(dataRanges)); } final Map<String,Object> modifiers = new HashMap<>(8); modifiers.put("statistics", statistics); @@ -652,7 +652,8 @@ public class RenderingData implements Cloneable { } /* * Apply a map projection on the image, then convert the floating point results to integer values - * that we can use with IndexColorModel. + * that we can use with `IndexColorModel`. The two operations (resampling and conversions) are + * combined in a single "visualization" operation of efficiency. * * TODO: if `colors` is null, instead of defaulting to `ColorModelBuilder.GRAYSCALE` we should get the colors * from the current ColorModel. This work should be done in `ColorModelBuilder` by converting the ranges @@ -661,13 +662,30 @@ public class RenderingData implements Cloneable { */ if (CREATE_INDEX_COLOR_MODEL) { final ColorModelType ct = ColorModelType.find(recoloredImage.getColorModel()); - if (ct.isSlow || (ct.useColorRamp && processor.getCategoryColors() != null)) { - return processor.visualize(recoloredImage, bounds, displayToCenter, dataRanges); + if (ct.isSlow || (ct.useColorRamp && processor.getColorizer() != null)) { + return processor.visualize(withSampleDimensions(recoloredImage), bounds, displayToCenter); } } return processor.resample(recoloredImage, bounds, displayToCenter); } + /** + * Returns an image augmented with the sample dimensions if not already present. + * If the property is present but with a different value, the {@link #dataRanges} + * will overwrite the image property value. + * + * @param image the image for which to add a property if not already present. + * @return image augmented with the given property. + */ + private RenderedImage withSampleDimensions(RenderedImage image) { + final String key = PlanarImage.SAMPLE_DIMENSIONS_KEY; + final SampleDimension[] value = dataRanges; + if (!Objects.deepEquals(image.getProperty(key), value)) { + image = processor.addUserProperties(image, Map.of(key, value)); + } + return image; + } + /** * Conversion or transformation from {@linkplain PlanarCanvas#getObjectiveCRS() objective CRS} to * {@linkplain #data} CRS. This transform will include {@code WraparoundTransform} steps if needed. diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java index 497a3b8b0e..fc83cb467c 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java @@ -473,24 +473,24 @@ abstract class RasterStore extends PRJDataStore implements GridCoverageResource final GridCoverage2D createCoverage(final GridGeometry domain, final RangeArgument range, final WritableRaster data, final Statistics stats) { + final SampleDimension[] bands = range.select(sampleDimensions); Hashtable<String,Object> properties = null; if (stats != null) { final Statistics[] as = new Statistics[range.getNumBands()]; Arrays.fill(as, stats); properties = new Hashtable<>(); properties.put(PlanarImage.STATISTICS_KEY, as); + properties.put(PlanarImage.SAMPLE_DIMENSIONS_KEY, bands); } - List<SampleDimension> bands = sampleDimensions; ColorModel cm = colorModel; if (!range.isIdentity()) { - bands = Arrays.asList(range.select(sampleDimensions)); cm = range.select(cm); if (cm == null) { - final SampleDimension band = bands.get(VISIBLE_BAND); + final SampleDimension band = bands[VISIBLE_BAND]; cm = ColorModelFactory.createGrayScale(data.getSampleModel(), VISIBLE_BAND, band.getSampleRange().orElse(null)); } } - return new GridCoverage2D(domain, bands, new BufferedImage(cm, data, false, properties)); + return new GridCoverage2D(domain, Arrays.asList(bands), new BufferedImage(cm, data, false, properties)); } /**