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 acdc88439434ec9195caa424c671018fd2d1355f Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Mon Dec 16 00:29:40 2024 +0100 Add an image overlay operation in `ImageProcessor`. --- .../sis/coverage/privy/BandAggregateArgument.java | 2 +- .../sis/coverage/privy/ColorModelBuilder.java | 9 +- .../apache/sis/coverage/privy/ImageUtilities.java | 14 + .../org/apache/sis/feature/internal/Resources.java | 3 +- .../sis/feature/internal/Resources.properties | 2 +- .../sis/feature/internal/Resources_fr.properties | 2 +- .../org/apache/sis/image/BandAggregateImage.java | 4 +- .../org/apache/sis/image/BandAggregateLayout.java | 2 +- .../main/org/apache/sis/image/BandSelectImage.java | 6 +- .../apache/sis/image/BandedSampleConverter.java | 9 +- .../main/org/apache/sis/image/Colorizer.java | 2 +- .../main/org/apache/sis/image/ComputedImage.java | 23 +- .../main/org/apache/sis/image/ImageAdapter.java | 4 +- .../main/org/apache/sis/image/ImageOverlay.java | 370 +++++++++++++++++++++ .../main/org/apache/sis/image/ImageProcessor.java | 106 +++++- .../org/apache/sis/image/MultiSourceImage.java | 41 ++- .../main/org/apache/sis/image/PlanarImage.java | 29 +- .../main/org/apache/sis/image/RecoloredImage.java | 60 +++- .../main/org/apache/sis/image/ResampledImage.java | 1 + .../org/apache/sis/image/StatisticsCalculator.java | 13 +- .../main/org/apache/sis/image/Visualization.java | 11 +- .../apache/sis/image/WritableComputedImage.java | 2 +- .../org/apache/sis/image/ImageOverlayTest.java | 114 +++++++ .../org/apache/sis/storage/geotiff/WriterTest.java | 12 +- .../org/apache/sis/storage/esri/RasterStore.java | 6 +- .../sis/util/resources/IndexedResourceBundle.java | 19 ++ 26 files changed, 774 insertions(+), 92 deletions(-) diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/BandAggregateArgument.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/BandAggregateArgument.java index a4c9737149..f4a5efe523 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/BandAggregateArgument.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/BandAggregateArgument.java @@ -345,7 +345,7 @@ public final class BandAggregateArgument<S> { * @throws IllegalArgumentException if some band indices are duplicated or outside their range of validity. */ private void validate(final Function<S, List<SampleDimension>> getter, final ToIntFunction<S> counter) { - final HashMap<Integer,int[]> identityPool = new HashMap<>(); + final var identityPool = new HashMap<Integer,int[]>(); numBandsPerSource = new int[sources.length]; next: for (int i=0; i<sources.length; i++) { // `sources.length` may change during the loop. S source; diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelBuilder.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelBuilder.java index 6a9327bc71..1501c36951 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelBuilder.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelBuilder.java @@ -271,13 +271,14 @@ public final class ColorModelBuilder { /** * Creates a <abbr>RGB</abbr> color model for the given sample model. * The sample model shall use integer type and have 3 or 4 bands. - * This method may return {@code null} if the color model cannot be created. + * If no <abbr>RGB</abbr> or <abbr>ARGB</abbr> color model can be created, + * this method default on a gray scale color model. * * @param targetModel the sample model for which to create a color model. - * @return the color model, or {@code null} if the given sample model is not supported. + * @return the <abbr>RGB</abbr> color model, or a gray scale color model as a fallback. * @throws IllegalArgumentException if any argument specified to the builder is invalid. */ - public ColorModel create(final SampleModel targetModel) { + public ColorModel createRGB(final SampleModel targetModel) { check: if (ImageUtilities.isIntegerType(targetModel)) { final int numBands = targetModel.getNumBands(); switch (numBands) { @@ -297,6 +298,6 @@ check: if (ImageUtilities.isIntegerType(targetModel)) { return createPackedRGB(); } } - return null; + return ColorModelFactory.createGrayScale(targetModel, ColorModelFactory.DEFAULT_VISIBLE_BAND, null); } } diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java index 3595c187f7..123bfeff39 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java @@ -611,6 +611,20 @@ public final class ImageUtilities extends Static { return r; } + /** + * Converts tile indices from the specified source image to the specified target image. + * + * @param source image for which tile indices are given. + * @param tartet image for which tile indices are desired. + * @param tiles ranges of indices of tiles in the source image. + * @return ranges of indices of tiles in the target image. + */ + public static Rectangle convertTileIndices(RenderedImage source, RenderedImage target, Rectangle tiles) { + Rectangle pixels = tilesToPixels(source, tiles); + clipBounds(target, pixels); + return pixelsToTiles(target, pixels); + } + /** * If scale and shear coefficients are close to integers, replaces their current values by their rounded values. * The scale and shear coefficients are handled in a "all or nothing" way; either all of them or none are rounded. diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java index 9b33976f3e..3bdf50aeb5 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java @@ -285,8 +285,7 @@ public class Resources extends IndexedResourceBundle { public static final short IterationNotStarted = 39; /** - * Image number of bands {0,number} does not match the number of sample dimensions - * ({1,number}). + * The image has {0,number} bands while the coverage has {1,number} sample dimensions. */ public static final short MismatchedBandCount_2 = 40; diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties index 9caf223d54..df2a468bba 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties @@ -64,7 +64,7 @@ InsufficientBufferCapacity_3 = Data buffer capacity is insufficient for a g IterationIsFinished = Iteration is finished. IterationNotStarted = Iteration did not started. InvalidSampleDimensionIndex_2 = Sample dimension index {1} is invalid. Expected an index from 0 to {0} inclusive. -MismatchedBandCount_2 = Image number of bands {0,number} does not match the number of sample dimensions ({1,number}). +MismatchedBandCount_2 = The image has {0,number} bands while the coverage has {1,number} sample dimensions. MismatchedBandSize = The bands have different number of sample values. MismatchedDataType = The bands store sample values using different data types. MismatchedGeometryLibrary_2 = Expected a geometry from {0} library but got {1}. diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties index 3d685e17ac..4dfb9f41af 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties @@ -69,7 +69,7 @@ InsufficientBufferCapacity_3 = La capacit\u00e9 du buffer est insuffisante IterationIsFinished = L\u2019it\u00e9ration est termin\u00e9e. IterationNotStarted = L\u2019it\u00e9ration n\u2019a pas commenc\u00e9e. InvalidSampleDimensionIndex_2 = L\u2019index de dimension d\u2019\u00e9chantillonnage {1} est invalide. On attendait un index de 0 \u00e0 {0} inclusif. -MismatchedBandCount_2 = Le nombre de bandes de l\u2019image ({0,number}) ne correspond pas au nombre de dimensions d\u2019\u00e9chantillonnage ({1,number}). +MismatchedBandCount_2 = L\u2019image a {0,number} bandes alors que la couverture a {1,number} dimensions d\u2019\u00e9chantillonnage. MismatchedBandSize = Les bandes ont un nombre diff\u00e9rent de valeurs. MismatchedDataType = Les bandes stockent leurs valeurs en utilisant des types de donn\u00e9es diff\u00e9rents. MismatchedGeometryLibrary_2 = Une g\u00e9om\u00e9tries {0} \u00e9tait attendue mais l\u2019objet re\u00e7u est de {1}. diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateImage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateImage.java index 47b8bdc130..94cba95aa4 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateImage.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateImage.java @@ -168,11 +168,13 @@ class BandAggregateImage extends MultiSourceImage { * * @param layout pixel and tile coordinate spaces of this image, together with sample model. * @param colorizer provider of color model to use for this image, or {@code null} for automatic. + * @param parallel whether parallel computation is allowed. */ private BandAggregateImage(final BandAggregateLayout layout, final Colorizer colorizer, final boolean allowSharing, final boolean parallel) { - super(layout, colorizer, parallel); + super(layout.filteredSources, layout.domain, layout.getMinTile(), + layout.sampleModel, layout.createColorModel(colorizer), parallel); this.allowSharing = allowSharing; } diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateLayout.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateLayout.java index 744c9d84df..963b538255 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateLayout.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateLayout.java @@ -101,7 +101,7 @@ final class BandAggregateLayout extends ImageLayout { * * @see #getMinTile() */ - final int minTileX, minTileY; + private final int minTileX, minTileY; /** * Whether to use the preferred tile size exactly as specified, without trying to compute a better size. diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandSelectImage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandSelectImage.java index 3dbde637a6..b9a3727cd2 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandSelectImage.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandSelectImage.java @@ -47,7 +47,7 @@ import org.apache.sis.coverage.privy.ObservableImage; */ class BandSelectImage extends SourceAlignedImage { /** - * Properties to inherit from the source image, after bands reduction if applicable. + * Properties to inherit from the source images, after bands reduction if applicable. * * @see #getProperty(String) */ @@ -80,7 +80,7 @@ class BandSelectImage extends SourceAlignedImage { private BandSelectImage(final RenderedImage source, final ColorModel cm, final int[] bands) { super(source, cm, source.getSampleModel().createSubsetSampleModel(bands)); this.bands = bands; - ensureCompatible(cm); + ensureCompatible(sampleModel, cm); } /** @@ -145,7 +145,7 @@ class BandSelectImage extends SourceAlignedImage { if (cm != null && source instanceof BufferedImage) { final BufferedImage bi = (BufferedImage) source; @SuppressWarnings("UseOfObsoleteCollectionType") - final Hashtable<String,Object> properties = new Hashtable<>(8); + final var properties = new Hashtable<String,Object>(8); for (final String key : INHERITED_PROPERTIES) { final Object value = getProperty(bi, key, bands); if (value != Image.UndefinedProperty) { diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandedSampleConverter.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandedSampleConverter.java index 43153dfab3..d120f30c34 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandedSampleConverter.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandedSampleConverter.java @@ -126,7 +126,7 @@ class BandedSampleConverter extends WritableComputedImage { this.colorModel = colorModel; this.converters = converters; this.sampleDimensions = sampleDimensions; - ensureCompatible(colorModel); + ensureCompatible(sampleModel, colorModel); /* * 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 @@ -403,12 +403,11 @@ class BandedSampleConverter extends WritableComputedImage { * forwards the notification to it. Otherwise default implementation does nothing. */ @Override - protected Disposable prefetch(final Rectangle tiles) { + protected Disposable prefetch(Rectangle tiles) { final RenderedImage source = getSource(); if (source instanceof PlanarImage) { - final Rectangle pixels = ImageUtilities.tilesToPixels(this, tiles); - ImageUtilities.clipBounds(source, pixels); - return ((PlanarImage) source).prefetch(ImageUtilities.pixelsToTiles(source, pixels)); + tiles = ImageUtilities.convertTileIndices(this, source, tiles); + return ((PlanarImage) source).prefetch(tiles); } else { return super.prefetch(tiles); } diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java index cc55809967..18a8f28754 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java @@ -161,7 +161,7 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode * The color model is <abbr>RGB</abbr> for image having 3 bands, or <abbr>ARGB</abbr> for images having 4 bands. * In the latter case, the color components are considered <em>not</em> premultiplied by the alpha value. */ - Colorizer ARGB = (target) -> Optional.ofNullable(new ColorModelBuilder().create(target.getSampleModel())); + Colorizer ARGB = (target) -> Optional.ofNullable(new ColorModelBuilder().createRGB(target.getSampleModel())); /** * Creates a colorizer which will interpolate the given colors in the given range of values. diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java index 3e5fa5fa56..0f327285f5 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java @@ -29,13 +29,11 @@ import java.awt.image.Raster; import java.awt.image.WritableRaster; import java.awt.image.WritableRenderedImage; import java.awt.image.RenderedImage; -import java.awt.image.ColorModel; import java.awt.image.SampleModel; import java.awt.image.TileObserver; import java.awt.image.ImagingOpException; import org.apache.sis.util.ArgumentChecks; import org.apache.sis.util.ArraysExt; -import org.apache.sis.util.Classes; import org.apache.sis.util.Disposable; import org.apache.sis.util.Exceptions; import org.apache.sis.util.privy.Numerics; @@ -262,24 +260,6 @@ public abstract class ComputedImage extends PlanarImage implements Disposable { reference = new ComputedTiles(this, ws); // Create cleaner last after all arguments have been validated. } - /** - * Ensures that a user supplied color model is compatible with the sample model. - * This is a helper method for argument validation in sub-classes constructors. - * - * @param colors the color model to validate. Can be {@code null}. - * @throws IllegalArgumentException if the color model is incompatible. - */ - final void ensureCompatible(final ColorModel colors) { - final String reason = verifyCompatibility(sampleModel, colors); - if (reason != null) { - String message = Resources.format(Resources.Keys.IncompatibleColorModel); - if (!reason.isEmpty()) { - message = message + ' ' + Errors.format(Errors.Keys.IllegalValueForProperty_2, Classes.getShortClassName(colors), reason); - } - throw new IllegalArgumentException(message); - } - } - /** * Returns a weak reference to this image. Using weak reference instead of strong reference may help to * reduce memory usage when recomputing the image is cheap. This method should not be public because the @@ -530,6 +510,7 @@ public abstract class ComputedImage extends PlanarImage implements Disposable { * and `releaseWritableTile(…)` method calls. */ int min; + @SuppressWarnings("LocalVariableHidesMemberVariable") final WritableRenderedImage destination = this.destination; // Protect from change (paranoiac). final boolean writeInDestination = (destination != null) && (tileX >= (min = destination.getMinTileX()) && tileX < min + destination.getNumXTiles()) @@ -848,7 +829,7 @@ public abstract class ComputedImage extends PlanarImage implements Disposable { */ final boolean equalsBase(final Object object) { if (object != null && getClass().equals(object.getClass())) { - final ComputedImage other = (ComputedImage) object; + final var other = (ComputedImage) object; return Arrays .equals(sources, other.sources) && Objects.equals(destination, other.destination) && sampleModel.equals(other.sampleModel); diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageAdapter.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageAdapter.java index a788831dee..ad308f8b47 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageAdapter.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageAdapter.java @@ -69,7 +69,7 @@ abstract class ImageAdapter extends PlanarImage { @Override @SuppressWarnings("UseOfObsoleteCollectionType") public final Vector<RenderedImage> getSources() { - final Vector<RenderedImage> sources = new Vector<>(1); + final var sources = new Vector<RenderedImage>(1); sources.add(source); return sources; } @@ -180,7 +180,7 @@ abstract class ImageAdapter extends PlanarImage { */ @Override public String toString() { - final StringBuilder buffer = new StringBuilder(100); + final var buffer = new StringBuilder(100); final Class<?> subtype = appendStringContent(buffer.append('[')); return buffer.insert(0, subtype.getSimpleName()).append(" on ").append(source).append(']').toString(); } diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java new file mode 100644 index 0000000000..2810bfd375 --- /dev/null +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java @@ -0,0 +1,370 @@ +/* + * 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.awt.Image; +import java.awt.Point; +import java.awt.Dimension; +import java.awt.Rectangle; +import java.awt.image.Raster; +import java.awt.image.ColorModel; +import java.awt.image.SampleModel; +import java.awt.image.RenderedImage; +import java.awt.image.WritableRaster; +import java.util.Objects; +import java.util.LinkedHashMap; +import java.util.function.Function; +import java.util.function.BiConsumer; +import javax.measure.Quantity; +import javax.measure.UnconvertibleException; +import org.apache.sis.util.ArraysExt; +import org.apache.sis.util.logging.Logging; +import org.apache.sis.math.Statistics; +import org.apache.sis.measure.Quantities; +import org.apache.sis.feature.internal.Resources; +import org.apache.sis.coverage.privy.ImageLayout; +import org.apache.sis.coverage.privy.ImageUtilities; + + +/** + * An overlay of an arbitrary number of images. All images have the same pixel coordinate system, + * but potentially different bounding boxes, tile sizes and tile indices. Source images are drawn + * in reverse order: the last source image is drawn first, and the first source image is drawn last + * on top of all other images. The requirements are: + * + * <ul> + * <li>All source images shall have the same pixel coordinate systems (but not necessarily the same tile matrix).</li> + * <li>All source images shall have the same number of bands (but not necessarily the same sample model).</li> + * <li>All source images should have equivalent color model, otherwise color consistency is not guaranteed.</li> + * <li>At least one image shall intersect the given bounds.</li> + * </ul> + * + * This class can also be opportunistically used for reformatting a single image. + * + * @author Martin Desruisseaux (Geomatys) + */ +final class ImageOverlay extends MultiSourceImage { + /** + * Creates a new image overlay or returns one of the given sources if equivalent. + * All source images shall have the same pixels coordinate system and the same number of bands. + * The returned image may have less sources than the specified ones if this method determines + * that some sources will never be drawn. This method may return {@code sources[0]} directly. + * + * @param sources the images to overlay. Null array elements are ignored. + * @param bounds range of pixel coordinates, or {@code null} for the union of all source images. + * @param sampleModel the sample model, of {@code null} for automatic. + * @param colorModel the color model, of {@code null} for automatic. + * @param autoTileSize whether this method is allowed to change the tile size. + * @param parallel whether parallel computation is allowed. + * @return the image overlay, or one of the given sources if only one is suitable. + * @throws IllegalArgumentException if there is an incompatibility between some source images + * or if no image intersect the bounds. + */ + static RenderedImage create(RenderedImage[] sources, Rectangle bounds, SampleModel sampleModel, ColorModel colorModel, + final boolean autoTileSize, final boolean parallel) + { + /* + * Filter the source images for keeping only the ones that intersect the bounds. + * Check image compatibility (number of bands) and color model in the same loop. + * If there is only one image left after filtering, it may be returned directly. + */ + int numBands=0, count=0; + final boolean computeUnion = (bounds == null); + final var sourceBounds = new Rectangle[sources.length]; + sources = sources.clone(); +next: for (final RenderedImage source : sources) { + final int n = ImageUtilities.getNumBands(source); + if (n == 0) continue; // Skip null elements. + if (n != numBands) { + if (numBands != 0) { + throw new IllegalArgumentException(Resources.format(Resources.Keys.UnexpectedNumberOfBands_2, numBands, n)); + } + numBands = n; + } + /* + * If the current source does not intersect the specified area of interest, or if a previous source + * fully overlaps the current source, then the latter image will never be drawn and can be omitted. + */ + Rectangle aoi = ImageUtilities.getBounds(source); + if (computeUnion) { + if (bounds == null) { + bounds = new Rectangle(aoi); + } else { + bounds.add(aoi); + } + } else { + aoi = aoi.intersection(bounds); + if (aoi.isEmpty()) continue; + } + for (int i=0; i<count; i++) { + if (sourceBounds[i].contains(aoi)) { + continue next; + } + } + sourceBounds[count] = aoi; + sources[count++] = source; + /* + * The default sample model is selected after filtering because the choice of a sample model + * does not change the visual, while it has an incidence on performance: it is better if the + * tile matrix of this image matches the tile matrix of the main image. The choice of a color + * model may change the visual, but is kept together with the sample model for simplicity and + * for reducing the risk that an image is rendered with the wrong colors. + */ + if (sampleModel == null) { + sampleModel = source.getSampleModel(); // Should never be null. + } + if (colorModel == null) { + final ColorModel candidate = source.getColorModel(); + if (candidate != null && candidate.isCompatibleSampleModel(sampleModel)) { + colorModel = candidate; + } + } + } + /* + * Except if there is no image, the sample model should be non-null at this point. + * However, the color model may still be null if none was specified in argument and + * no compatible color model was found in the source images. Leave thet color model + * to null (i.e., we don't invent colors when we don't know what they should be). + */ + if (count == 0) { + throw new IllegalArgumentException(Resources.format(Resources.Keys.SourceImagesDoNotIntersect)); + } + final RenderedImage main = sources[0]; + if (count == 1 && sampleModel.equals(main.getSampleModel())) { + return (colorModel != null) ? RecoloredImage.apply(main, colorModel) : main; + } + sources = ArraysExt.resize(sources, count); + /* + * If the tile size is not a divisor of the image size, try to find a better tile size. + */ + if (autoTileSize) { + var tileSize = new Dimension(sampleModel.getWidth(), sampleModel.getHeight()); + if ((bounds.width % tileSize.width) != 0 || (bounds.height % tileSize.height) != 0) { + tileSize = new ImageLayout(tileSize, false).suggestTileSize(bounds.width, bounds.height, true); + sampleModel = sampleModel.createCompatibleSampleModel(tileSize.width, tileSize.height); + } + } + var minTile = new Point(ImageUtilities.pixelToTileX(main, bounds.x), + ImageUtilities.pixelToTileY(main, bounds.y)); + return ImageProcessor.unique(new ImageOverlay(sources, bounds, minTile, sampleModel, colorModel, parallel)); + } + + /** + * Creates a new image overlay. + */ + private ImageOverlay(final RenderedImage[] sources, final Rectangle bounds, final Point minTile, + final SampleModel sampleModel, final ColorModel colorModel, + final boolean parallel) + { + super(sources, bounds, minTile, sampleModel, colorModel, parallel); + } + + /** + * Returns the names of all recognized properties, or {@code null} if this image has no properties. + * The implementation iterates over all sources images on the assumption that there is not many of them. + * We do not cache the result for making sure that any change in the sources is reflected here. + */ + @Override + public String[] getPropertyNames() { + final int n = getNumSources(); + final var count = new LinkedHashMap<String,Integer>(); + for (int i=0; i<n; i++) { + final String[] names = getSource(i).getPropertyNames(); + if (names != null) { + for (String name : names) { + /* + * This switch shall contain the same cases as in the `getProperty(String)` method. + * For properties considered present as soon as it is defined in at least one source, + * we set the count directly to `n`. For properties that must be present in all sources, + * we count their occurrences. + */ + switch (name) { + case GRID_GEOMETRY_KEY: + case SAMPLE_DIMENSIONS_KEY: + case POSITIONAL_ACCURACY_KEY: count.put(name, n); break; + case SAMPLE_RESOLUTIONS_KEY: + case STATISTICS_KEY: count.merge(name, 1, Math::addExact); break; + } + } + } + } + count.values().removeIf((v) -> v != n); + return count.isEmpty() ? null : count.keySet().toArray(String[]::new); + } + + /** + * Gets the property of the given name. Each property is derived from the source images in its own way. + * For example, {@link #STATISTICS_KEY} is computed by combining the statistics provided by each source, + * while {@link #SAMPLE_RESOLUTIONS_KEY} takes for each band the minimal values of all sources. + * + * <h4>Implementation note</h4> + * This method does not cache the property values on the assumption that there is not many sources, + * that these sources already have their own cache and that merging the values is efficient enough. + * This approach avoids the need to clone the cached values and to respond to events that may change + * the cached values. + * + * @param name name of the property to compute. + * @return property value (may be {@code null}), or {@link Image#UndefinedProperty} if none. + */ + @Override + public Object getProperty(final String key) { + switch (key) { + case GRID_GEOMETRY_KEY: // Fall through + case SAMPLE_DIMENSIONS_KEY: return getConstantProperty(key); + case POSITIONAL_ACCURACY_KEY: return getCombinedProperty(key, Quantity[].class, (q) -> q.clone(), ImageOverlay::combine, false); + case SAMPLE_RESOLUTIONS_KEY: return getCombinedProperty(key, double[].class, double[]::clone, ImageOverlay::combine, true); + case STATISTICS_KEY: return getCombinedProperty(key, Statistics[].class, StatisticsCalculator::clone, ImageOverlay::combine, true); + default: return Image.UndefinedProperty; + } + } + + /** + * Returns a property value which is expected to be constant in all source images, ignoring undefined values. + * If the property is not constant, then this method returns {@link Image#UndefinedProperty}. + * + * @param key name of the property to get. + * @return property value (may be {@code null}), or {@link Image#UndefinedProperty} if none. + */ + private Object getConstantProperty(final String key) { + Object result = Image.UndefinedProperty; + final int n = getNumSources(); + for (int i=0; i<n; i++) { + final Object c = getSource(i).getProperty(key); + if (c != Image.UndefinedProperty) { + if (result == Image.UndefinedProperty) { + result = c; + } else if (!Objects.deepEquals(result, c)) { + return Image.UndefinedProperty; + } + } + } + return result; + } + + /** + * Returns a property value which is computed by combining the values from all source images. + * Undefined values are ignored if {@code required} is false. If {@code required} is true, + * then any missing value will cause this method to return {@link Image#UndefinedProperty}. + * + * @param <V> compile-time value of the {@code type} argument. + * @param key name of the property to get. + * @param type type of values to combine. Often an array type. + * @param cloner method creating a clone of the first value found. + * @param combiner method updating the clone with more values. + * @param required whether the property must be provided in all images for being considered defined. + * @return property value, or {@link Image#UndefinedProperty} if none. + */ + private <V> Object getCombinedProperty(final String key, final Class<V> type, + final Function<V,V> cloner, final BiConsumer<V,V> combiner, final boolean required) + { + V result = null; + final int n = getNumSources(); + for (int i=0; i<n; i++) { + final Object value = getSource(i).getProperty(key); + if (type.isInstance(value)) { + @SuppressWarnings("unchecked") + final V c = (V) value; + if (result == null) { + result = cloner.apply(c); + } else try { + combiner.accept(result, c); + } catch (UnconvertibleException e) { + Logging.recoverableException(ImageUtilities.LOGGER, ImageOverlay.class, "getProperty", e); + return Image.UndefinedProperty; + } + } else if (required) { + return Image.UndefinedProperty; + } + } + return (result != null) ? result : Image.UndefinedProperty; + } + + /** + * Combines the statistics of previous source images with statistics of a new source image. + * This method is invoked for computing the {@value #STATISTICS_KEY} property. + * + * @param result combination done so for. + * @param more statistics of another source to combine. + */ + private static void combine(final Statistics[] result, final Statistics[] more) { + for (int i = Math.min(result.length, more.length); --i >= 0;) { + result[i].combine(more[i]); + } + } + + /** + * Combines the resolution of previous source images with resolution of a new source image. + * This method is invoked for computing the {@value #SAMPLE_RESOLUTIONS_KEY} property. + * The minimum value is retained because this property is about resolution, not accuracy. + * It is used for computing the number of fraction digits needed to distinguish the values of two cells. + * + * @param result combination done so for. + * @param more resolution of another source to combine. + */ + private static void combine(final double[] result, final double[] more) { + for (int i = Math.min(result.length, more.length); --i >= 0;) { + final double value = more[i]; + final double previous = result[i]; + if (value < previous || Double.isNaN(previous)) { + result[i] = value; + } + } + } + + /** + * Combines the positional accuracy of previous source images with accuracy of a new source image. + * This method is invoked for computing the {@value #POSITIONAL_ACCURACY_KEY} property. + * + * <p>This method signature is unsafe. However, Apache <abbr>SIS</abbr> implementation of + * the {@link Quantities#min(Quantity, Quantity)} method performs the required checks.</p> + * + * @param result combination done so for. + * @param more positional accuracy of another source to combine. + * @throws UnconvertibleException if the quantities are not comparable. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) // See method Javadoc. + private static void combine(final Quantity[] result, final Quantity[] more) { + for (int i = Math.min(result.length, more.length); --i >= 0;) { + result[i] = Quantities.max(result[i], more[i]); + } + } + + /** + * Computes the tile at specified indices. + * + * @param tileX the column index of the tile to compute. + * @param tileY the row index of the tile to compute. + * @param target if the tile already exists but needs to be updated, the tile to update. Otherwise {@code null}. + * @return computed tile for the given indices (cannot be null). + */ + @Override + protected Raster computeTile(final int tileX, final int tileY, WritableRaster target) { + if (target == null) { + target = createTile(tileX, tileY); + } + final int n = getNumSources(); + for (int i=n; --i >= 0;) { + final RenderedImage source = getSource(i); + final Rectangle bounds = getBounds(); + ImageUtilities.clipBounds(source, bounds); + if (!bounds.isEmpty()) { + copyData(bounds, source, target); + } + } + return target; + } +} diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java index 6531270223..db2faaefa2 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java @@ -169,6 +169,15 @@ public class ImageProcessor implements Cloneable { */ private ImageLayout layout; + /** + * Whether the processor is allowed to change the tile size. This configuration is relevant + * only for operations taking a {@link SampleModel} in argument, which implies a tile size. + * + * @see Resizing#CHANGE_TILING + * @see #setImageResizingPolicy(Resizing) + */ + private boolean autoTileSize; + /** * Whether {@code ImageProcessor} can produce an image of different size compared to requested size. * An image may be resized if the requested size cannot be subdivided into tiles of reasonable size. @@ -183,12 +192,21 @@ public class ImageProcessor implements Cloneable { */ public enum Resizing { /** - * Image size is unmodified; the requested value is used unconditionally. + * Image size is unmodified, the requested value is used unconditionally. * It may result in big tiles (potentially a single tile for the whole image) * if the image size is not divisible by a tile size. */ NONE, + /** + * The tile size can be modified, but not the image size. This resizing policy can + * be used with operations where a {@link SampleModel} argument implies a tile size. + * For other operations, this resizing policy is equivalent to {@link #NONE}. + * + * @since 1.5 + */ + CHANGE_TILING, + /** * Image size can be increased. {@code ImageProcessor} will try to increase * by the smallest number of pixels allowing the image to be subdivided in tiles. @@ -398,7 +416,8 @@ public class ImageProcessor implements Cloneable { * @return the image resizing policy. */ public synchronized Resizing getImageResizingPolicy() { - return layout.isBoundsAdjustmentAllowed ? Resizing.EXPAND : Resizing.NONE; + return layout.isBoundsAdjustmentAllowed ? Resizing.EXPAND : + autoTileSize ? Resizing.CHANGE_TILING : Resizing.NONE; } /** @@ -410,6 +429,7 @@ public class ImageProcessor implements Cloneable { layout = (Objects.requireNonNull(policy) == Resizing.EXPAND) ? ImageLayout.SIZE_ADJUST : ImageLayout.DEFAULT; + autoTileSize = (policy == Resizing.CHANGE_TILING); } /** @@ -933,6 +953,7 @@ public class ImageProcessor implements Cloneable { * * @since 1.4 */ + @SuppressWarnings("LocalVariableHidesMemberVariable") public RenderedImage aggregateBands(final RenderedImage[] sources, final int[][] bandsPerSource) { ArgumentChecks.ensureNonEmpty("sources", sources); final Colorizer colorizer; @@ -944,6 +965,83 @@ public class ImageProcessor implements Cloneable { return BandAggregateImage.create(sources, bandsPerSource, colorizer, true, true, parallel); } + /** + * Creates a new image overlay or returns one of the given sources if equivalent. + * All source images shall have the same pixel coordinate system, but they may have different bounding boxes, + * tile sizes and tile indices. Images are drawn in reverse order: the last source image is drawn first, and + * the first source image is drawn last on top of all other images. The returned image may have less sources + * than the specified ones if this method determines that some sources will never be drawn. + * This method may return {@code sources[0]} directly. + * + * <p>All source images shall have the same number of bands (but not necessarily the same sample model). + * All source images should have equivalent color model, otherwise color consistency is not guaranteed. + * At least one image shall intersect the given bounds.</p> + * + * <h4>Properties used</h4> + * This operation uses the following properties in addition to method parameters: + * <ul> + * <li>{@linkplain #getImageResizingPolicy() Image resizing policy} for specifying whether + * this method is allowed to change the tile size implied by the given sample model.</li> + * </ul> + * + * @param sources the images to overlay. Null array elements are ignored. + * @param bounds range of pixel coordinates, or {@code null} for the union of all source images. + * @param sampleModel the sample model, of {@code null} for automatic. + * @param colorModel the color model, of {@code null} for automatic. + * @return the image overlay, or one of the given sources if only one is suitable. + * @throws IllegalArgumentException if there is an incompatibility between some source images + * or if no image intersect the given bounds. + * + * @since 1.5 + */ + @SuppressWarnings("LocalVariableHidesMemberVariable") + public RenderedImage overlay(final RenderedImage[] sources, final Rectangle bounds, + final SampleModel sampleModel, final ColorModel colorModel) + { + ArgumentChecks.ensureNonEmpty("sources", sources); + final boolean parallel; + final boolean autoTileSize; + synchronized (this) { + autoTileSize = this.autoTileSize; + parallel = executionMode != Mode.SEQUENTIAL; + } + return ImageOverlay.create(sources, bounds, sampleModel, colorModel, autoTileSize | (bounds != null), parallel); + } + + /** + * Reformats the given image with a different sample model. + * This operation <em>copies</em> the pixel values in a new image. + * Despite the copies being done on a tile-by-tile basis when each tile is first requested, + * this is still a relatively costly operation compared to the usual Apache <abbr>SIS</abbr> + * approach of creating views as much as possible. Therefore, this method should be used only + * when necessary. + * + * <h4>Properties used</h4> + * This operation uses the following properties in addition to method parameters: + * <ul> + * <li>{@linkplain #getImageResizingPolicy() Image resizing policy} for specifying whether + * this method is allowed to change the tile size implied by the given sample model.</li> + * </ul> + * + * @param source the images to reformat. + * @param sampleModel the desired sample model. + * @return the reformatted image. + * + * @since 1.5 + */ + @SuppressWarnings("LocalVariableHidesMemberVariable") + public RenderedImage reformat(final RenderedImage source, final SampleModel sampleModel) { + ArgumentChecks.ensureNonNull("source", source); + ArgumentChecks.ensureNonNull("sampleModel", sampleModel); + final boolean parallel; + final boolean autoTileSize; + synchronized (this) { + autoTileSize = this.autoTileSize; + parallel = executionMode != Mode.SEQUENTIAL; + } + return ImageOverlay.create(new RenderedImage[] {source}, null, sampleModel, null, autoTileSize, parallel); + } + /** * Applies a mask defined by a geometric shape. If {@code maskInside} is {@code true}, * then all pixels inside the given shape are set to the {@linkplain #getFillValues() fill values}. @@ -965,6 +1063,7 @@ public class ImageProcessor implements Cloneable { * * @since 1.2 */ + @SuppressWarnings("LocalVariableHidesMemberVariable") public RenderedImage mask(final RenderedImage source, final Shape mask, final boolean maskInside) { ArgumentChecks.ensureNonNull("source", source); ArgumentChecks.ensureNonNull("mask", mask); @@ -1017,6 +1116,7 @@ public class ImageProcessor implements Cloneable { * * @since 1.4 */ + @SuppressWarnings("LocalVariableHidesMemberVariable") public RenderedImage convert(final RenderedImage source, final NumberRange<?>[] sourceRanges, MathTransform1D[] converters, final DataType targetType) { @@ -1084,6 +1184,7 @@ public class ImageProcessor implements Cloneable { * * @see GridCoverageProcessor#resample(GridCoverage, GridGeometry) */ + @SuppressWarnings("LocalVariableHidesMemberVariable") public RenderedImage resample(RenderedImage source, final Rectangle bounds, MathTransform toSource) { ArgumentChecks.ensureNonNull("source", source); ArgumentChecks.ensureNonNull("bounds", bounds); @@ -1389,6 +1490,7 @@ public class ImageProcessor implements Cloneable { * @return whether the other object is an image processor of the same class with the same configuration. */ @Override + @SuppressWarnings("LocalVariableHidesMemberVariable") public boolean equals(final Object object) { if (object != null && object.getClass() == getClass()) { final ImageProcessor other = (ImageProcessor) object; diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/MultiSourceImage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/MultiSourceImage.java index 1cb416dd13..957646ca0b 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/MultiSourceImage.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/MultiSourceImage.java @@ -20,6 +20,8 @@ import java.awt.Point; import java.awt.Rectangle; import java.util.Objects; import java.awt.image.ColorModel; +import java.awt.image.SampleModel; +import java.awt.image.RenderedImage; import java.awt.image.WritableRenderedImage; import org.apache.sis.coverage.privy.ImageUtilities; import org.apache.sis.util.Disposable; @@ -39,6 +41,7 @@ import org.apache.sis.util.Disposable; abstract class MultiSourceImage extends WritableComputedImage { /** * Color model of this image. + * A null value is allowed but not recommended. * * @see #getColorModel() */ @@ -66,22 +69,28 @@ abstract class MultiSourceImage extends WritableComputedImage { /** * Creates a new multi-sources image. * - * @param layout pixel and tile coordinate spaces of this image, together with sample model. - * @param colorizer provider of color model to use for this image, or {@code null} for automatic. - * @param parallel whether parallel computation is allowed. + * @param sources sources of this image. + * @param bounds range of pixel coordinates of this image. + * @param minTile indices of the first tile in this image. + * @param sampleModel the sample model shared by all tiles in this image. + * @param colorModel the color model of the image, or {@code null} if none. + * @param parallel whether parallel computation is allowed. + * @throws IllegalArgumentException if the color model is incompatible with the sample model. */ - MultiSourceImage(final BandAggregateLayout layout, final Colorizer colorizer, final boolean parallel) { - super(layout.sampleModel, layout.filteredSources); - final Rectangle r = layout.domain; - minX = r.x; - minY = r.y; - width = r.width; - height = r.height; - minTileX = layout.minTileX; - minTileY = layout.minTileY; - colorModel = layout.createColorModel(colorizer); - ensureCompatible(colorModel); - this.parallel = parallel; + MultiSourceImage(final RenderedImage[] sources, final Rectangle bounds, final Point minTile, + final SampleModel sampleModel, final ColorModel colorModel, + final boolean parallel) + { + super(sampleModel, sources); + this.colorModel = colorModel; + this.minX = bounds.x; + this.minY = bounds.y; + this.width = bounds.width; + this.height = bounds.height; + this.minTileX = minTile.x; + this.minTileY = minTile.y; + this.parallel = parallel; + ensureCompatible(sampleModel, colorModel); } /** Returns the information inferred at construction time. */ @@ -134,7 +143,7 @@ abstract class MultiSourceImage extends WritableComputedImage { @Override public boolean equals(final Object object) { if (equalsBase(object)) { - final MultiSourceImage other = (MultiSourceImage) object; + final var other = (MultiSourceImage) object; return parallel == other.parallel && minTileX == other.minTileX && minTileY == other.minTileY && diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java index cc3018309a..6fc40e2987 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java @@ -38,6 +38,7 @@ import org.apache.sis.coverage.grid.GridGeometry; // For javadoc import org.apache.sis.coverage.privy.ImageUtilities; import org.apache.sis.coverage.privy.TileOpExecutor; import org.apache.sis.coverage.privy.ColorModelFactory; +import org.apache.sis.feature.internal.Resources; import org.apache.sis.pending.jdk.JDK18; @@ -161,6 +162,9 @@ public abstract class PlanarImage implements RenderedImage { * This information can be used for choosing the number of fraction digits to show when writing sample values * in text format. * + * <p><em>Resolution is not accuracy.</em> + * There is no guarantee that the data accuracy is as good as the resolution given by this property.</p> + * * <p>Values should be instances of {@code double[]}. * The array length should be the number of bands. This property may be computed automatically during * {@linkplain org.apache.sis.coverage.grid.GridCoverage#forConvertedValues(boolean) conversions from @@ -463,7 +467,7 @@ public abstract class PlanarImage implements RenderedImage { * Copies an arbitrary rectangular region of this image to the supplied writable raster. * The region to be copied is determined from the bounds of the supplied raster. * The supplied raster must have a {@link SampleModel} that is compatible with this image. - * If the raster is {@code null}, an raster is created by this method. + * If the given raster is {@code null}, a new raster is created by this method. * * @param raster the raster to hold a copy of this image, or {@code null}. * @return the given raster if it was not-null, or a new raster otherwise. @@ -527,6 +531,27 @@ public abstract class PlanarImage implements RenderedImage { return null; } + /** + * Ensures that a user supplied color model is compatible with the sample model. + * This is a helper method for argument validation in sub-classes constructors. + * + * @param sampleModel the sample model of this image. + * @param colors the color model to validate. Can be {@code null}. + * @throws IllegalArgumentException if the color model is incompatible. + */ + static void ensureCompatible(final SampleModel sampleModel, final ColorModel colors) { + final String erroneous = verifyCompatibility(sampleModel, colors); + if (erroneous != null) { + String message = Resources.format(Resources.Keys.IncompatibleColorModel); + if (!erroneous.isEmpty()) { + String complement = Classes.getShortClassName(colors); + complement = Errors.format(Errors.Keys.IllegalValueForProperty_2, complement, erroneous); + message = Resources.concatenate(message, complement); + } + throw new IllegalArgumentException(message); + } + } + /** * Verifies if the color model is compatible with the sample model. * If the color model is incompatible, then this method returns the name of the mismatched property. @@ -537,7 +562,7 @@ public abstract class PlanarImage implements RenderedImage { * @return name of mismatched property (an empty string if unidentified), * or {@code null} if the color model is null or is compatible. */ - static String verifyCompatibility(final SampleModel sm, final ColorModel cm) { + private static String verifyCompatibility(final SampleModel sm, final ColorModel cm) { if (cm == null || cm.isCompatibleSampleModel(sm)) return null; if (cm.getTransferType() != sm.getTransferType()) return "transferType"; if (cm.getNumComponents() != sm.getNumBands()) return "numComponents"; diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/RecoloredImage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/RecoloredImage.java index 62e5fb1761..ac3569851d 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/RecoloredImage.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/RecoloredImage.java @@ -54,7 +54,7 @@ final class RecoloredImage extends ImageAdapter { private final ColorModel colors; /** - * The minimum and maximum values used for computing the color model. + * The minimum and maximum values used for computing the color model, or NaN if unknown. * This is used for preserving color ramp stretching when a new color ramp is applied. * * <p>Current implementation can only describes a uniform stretching between a minimum and maximum value. @@ -76,6 +76,48 @@ final class RecoloredImage extends ImageAdapter { this.colors = colors; this.minimum = minimum; this.maximum = maximum; + ensureCompatible(getSampleModel(), colors); + } + + /** + * Creates a new recolored image with the given colors and the same minimum/maximum values as the given parent. + * + * @param source the image to wrap. + * @param colors the new color model. + * @param parent the parent from which to inherit min/max, or {@code null} if none. + */ + private RecoloredImage(final RenderedImage source, final ColorModel colors, final RecoloredImage parent) { + super(source); + this.colors = colors; + if (parent != null) { + minimum = parent.minimum; + maximum = parent.maximum; + } else { + minimum = maximum = Double.NaN; + } + ensureCompatible(getSampleModel(), colors); + } + + /** + * Returns the given image with the given colors. + * + * @param source the image to wrap. + * @param colors the new color model. + * @return image with the given color model. May be a source returned directly. + */ + static RenderedImage apply(RenderedImage source, final ColorModel colors) { + RecoloredImage parent = null; + for (;;) { + if (colors.equals(source.getColorModel())) { + return source; + } + if (source instanceof RecoloredImage) { + parent = (RecoloredImage) source; + source = parent.source; + } else { + return ImageProcessor.unique(new RecoloredImage(source, colors, parent)); + } + } } /** @@ -110,7 +152,7 @@ final class RecoloredImage extends ImageAdapter { for (;;) { if (colors.equals(source.getColorModel())) { if (expected != null && source instanceof RecoloredImage) { - final RecoloredImage actual = (RecoloredImage) source; + final var actual = (RecoloredImage) source; if (!(Numerics.equals(expected.minimum, actual.minimum) && Numerics.equals(expected.maximum, actual.maximum))) { @@ -129,13 +171,7 @@ final class RecoloredImage extends ImageAdapter { * At this point we found no existing image with the desired color model, * or the minimum/maximum information would be lost. Create a new image. */ - final RecoloredImage image; - if (expected != null) { - image = new RecoloredImage(source, colors, expected.minimum, expected.maximum); - } else { - image = new RecoloredImage(source, colors, Double.NaN, Double.NaN); - } - return ImageProcessor.unique(image); + return ImageProcessor.unique(new RecoloredImage(source, colors, expected)); } /** @@ -267,7 +303,7 @@ final class RecoloredImage extends ImageAdapter { * But if there is 2 or more, then we select the one having largest intersection * with the [minimum … maximum] range. */ - final IndexColorModel icm = (IndexColorModel) source.getColorModel(); + final var icm = (IndexColorModel) source.getColorModel(); final int size = icm.getMapSize(); int validMin = 0; int validMax = size - 1; // Inclusive. @@ -321,7 +357,7 @@ final class RecoloredImage extends ImageAdapter { for (;;) { if (cm.equals(source.getColorModel())) { if (source instanceof RecoloredImage) { - final RecoloredImage colored = (RecoloredImage) source; + final var colored = (RecoloredImage) source; if (colored.minimum != minimum || colored.maximum != maximum) { continue; } @@ -382,7 +418,7 @@ final class RecoloredImage extends ImageAdapter { @Override public boolean equals(final Object object) { if (super.equals(object)) { - final RecoloredImage other = (RecoloredImage) object; + final var other = (RecoloredImage) object; return Numerics.equals(minimum, other.minimum) && Numerics.equals(maximum, other.maximum) && colors.equals(other.colors); diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ResampledImage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ResampledImage.java index afaeacf6d9..fe9393d9ea 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ResampledImage.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ResampledImage.java @@ -524,6 +524,7 @@ public class ResampledImage extends ComputedImage { * @return names of all recognized properties, or {@code null} if none. */ @Override + @SuppressWarnings("StringEquality") public String[] getPropertyNames() { final String[] inherited = getSource().getPropertyNames(); final String[] names = { diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/StatisticsCalculator.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/StatisticsCalculator.java index fcf3577f25..7a95a28c9a 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/StatisticsCalculator.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/StatisticsCalculator.java @@ -92,7 +92,7 @@ final class StatisticsCalculator extends AnnotatedImage { * This is used for both sequential and parallel executions. */ private static Statistics[] createAccumulator(final int numBands) { - final Statistics[] stats = new Statistics[numBands]; + final var stats = new Statistics[numBands]; for (int i=0; i<numBands; i++) { stats[i] = new Statistics(Vocabulary.formatInternational(Vocabulary.Keys.Band_1, i)); } @@ -110,7 +110,7 @@ final class StatisticsCalculator extends AnnotatedImage { if (sampleFilters == null) { return accumulator; } - final DoubleConsumer[] filtered = new DoubleConsumer[accumulator.length]; + final var filtered = new DoubleConsumer[accumulator.length]; for (int i=0; i<filtered.length; i++) { final DoubleConsumer c = accumulator[i]; final DoubleUnaryOperator f = sampleFilters[i]; @@ -161,7 +161,14 @@ final class StatisticsCalculator extends AnnotatedImage { */ @Override protected Object cloneProperty(final String name, final Object value) { - final Statistics[] result = ((Statistics[]) value).clone(); + return clone(((Statistics[]) value)); + } + + /** + * Clones the given array and all values in the array. + */ + static Statistics[] clone(Statistics[] result) { + result = result.clone(); for (int i=0; i<result.length; i++) { result[i] = result[i].clone(); } diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Visualization.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Visualization.java index ab61c53dfd..2789a30e86 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Visualization.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Visualization.java @@ -314,10 +314,13 @@ final class Visualization extends ResampledImage { initialized = builder.initialize(sourceCM); if (!initialized) { if (coloredSource instanceof RecoloredImage) { - final RecoloredImage colored = (RecoloredImage) coloredSource; - builder.initialize(colored.minimum, colored.maximum, sourceSM.getDataType()); - initialized = true; - } else { + final var colored = (RecoloredImage) coloredSource; + if (colored.minimum < colored.maximum) { // Do not execute if values are NaN. + builder.initialize(colored.minimum, colored.maximum, sourceSM.getDataType()); + initialized = true; + } + } + if (!initialized) { initialized = builder.initialize(sourceSM, visibleBand); } } diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/WritableComputedImage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/WritableComputedImage.java index 235d55a012..e6bf82c9ea 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/WritableComputedImage.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/WritableComputedImage.java @@ -142,7 +142,7 @@ abstract class WritableComputedImage extends ComputedImage { * @return the specified tile as a writable tile. */ public WritableRaster getWritableTile(final int tileX, final int tileY) { - final WritableRaster tile = (WritableRaster) getTile(tileX, tileY); + final var tile = (WritableRaster) getTile(tileX, tileY); markTileWritable(tileX, tileY, true); return tile; } diff --git a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/ImageOverlayTest.java b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/ImageOverlayTest.java new file mode 100644 index 0000000000..1a597f7c27 --- /dev/null +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/ImageOverlayTest.java @@ -0,0 +1,114 @@ +/* + * 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.Hashtable; +import java.awt.Rectangle; +import java.awt.Transparency; +import java.awt.color.ColorSpace; +import java.awt.image.ComponentColorModel; +import java.awt.image.BandedSampleModel; +import java.awt.image.DataBuffer; +import java.awt.image.RenderedImage; +import java.awt.image.BufferedImage; +import java.awt.image.WritableRaster; + +// Test dependencies +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import static org.junit.jupiter.api.Assertions.*; +import org.apache.sis.test.TestCase; +import static org.apache.sis.feature.Assertions.assertValuesEqual; + + +/** + * Tests {@link ImageOverlay}. + * + * @author Martin Desruisseaux (Geomatys) + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public final class ImageOverlayTest extends TestCase { + /** + * The image to use at the sources for the test. + * Should not be modified. + */ + private final BufferedImage[] sources; + + /** + * Creates a new test case. + */ + public ImageOverlayTest() { + final var properties = new Hashtable<String,Object>(); + final var cm = new ComponentColorModel(ColorSpace.getInstance(ColorSpace.CS_GRAY), false, false, Transparency.OPAQUE, DataBuffer.TYPE_BYTE); + sources = new BufferedImage[3]; + + properties.put("ShouldBeIgnored", "Dummy"); + properties.put(PlanarImage.SAMPLE_RESOLUTIONS_KEY, new double[] {3, 6, 1}); + sources[0] = new BufferedImage(cm, data(7, 3, 100), false, properties); + + properties.put(PlanarImage.SAMPLE_RESOLUTIONS_KEY, new double[] {2, 5, 3}); + sources[2] = new BufferedImage(cm, data(3, 5, 200), false, properties); + } + + /** + * Creates a raster for a source image. + */ + private static WritableRaster data(final int width, final int height, int value) { + final var sm = new BandedSampleModel(DataBuffer.TYPE_BYTE, width, height, 1); + final WritableRaster raster = WritableRaster.createWritableRaster(sm, null); + for (int y=0; y<height; y++) { + for (int x=0; x<width; x++) { + raster.setSample(x, y, 0, value++); + } + } + return raster; + } + + /** + * Tests an image created with the default argument values. + */ + @Test + public void testDefault() { + final RenderedImage image = ImageOverlay.create(sources, null, null, null, true, false); + assertEquals(2, image.getSources().size()); + assertEquals(7, image.getWidth()); + assertEquals(5, image.getHeight()); + assertEquals(7, image.getTileWidth()); + assertEquals(5, image.getTileHeight()); + assertEquals(1, image.getNumXTiles()); + assertEquals(1, image.getNumYTiles()); + assertArrayEquals(new String[] {PlanarImage.SAMPLE_RESOLUTIONS_KEY}, image.getPropertyNames()); + assertArrayEquals(new double[] {2, 5, 1}, (double[]) image.getProperty(PlanarImage.SAMPLE_RESOLUTIONS_KEY)); + assertValuesEqual(image.getData(), 0, new int[][] { + {100, 101, 102, 103, 104, 105, 106}, + {107, 108, 109, 110, 111, 112, 113}, + {114, 115, 116, 117, 118, 119, 120}, + {209, 210, 211, 0, 0, 0, 0}, + {212, 213, 214, 0, 0, 0, 0} + }); + } + + /** + * Tests with a subregion fully covered by the first image. + * The code should return the first image directly. + */ + @Test + public void testSubRegion() { + RenderedImage image = ImageOverlay.create(sources, new Rectangle(7, 3), null, null, true, false); + assertSame(sources[0], image); + } +} diff --git a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java index 16971a8f4e..0f33859697 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java @@ -237,7 +237,7 @@ public final class WriterTest extends TestCase { @Test public void testUntiledRGB() throws IOException, DataStoreException { initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 3, 1, 1); - image.setColorModel(new ColorModelBuilder().create(image.getSampleModel())); + image.setColorModel(new ColorModelBuilder().createRGB(image.getSampleModel())); writeImage(); verifyHeader(false, IOBase.LITTLE_ENDIAN); verifyImageFileDirectory(Writer.COMMON_NUMBER_OF_TAGS - 1, // One less tag because stripped layout. @@ -338,11 +338,11 @@ public final class WriterTest extends TestCase { */ short previousTag = 0; while (--tagCount >= 0) { - short tag = data.getShort(); - short type = data.getShort(); - long count = isBigTIFF ? data.getLong() : data.getInt(); - long value = isBigTIFF ? data.getLong() : data.getInt(); - Object expected; // The Number class will define the expected type. + short tag = data.getShort(); + short type = data.getShort(); + long count = isBigTIFF ? data.getLong() : data.getInt(); + long value = isBigTIFF ? data.getLong() : data.getInt(); + Object expected; // The Number class will define the expected type. assertTrue(Short.toUnsignedInt(tag) > Short.toUnsignedInt(previousTag), "Tags shall be sorted in increasing order."); expectedTags.remove(Integer.valueOf(tag)); diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java index e222f4711c..31d0accdbd 100644 --- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java +++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java @@ -343,7 +343,7 @@ abstract class RasterStore extends PRJDataStore implements GridCoverageResource final boolean isInteger = ImageUtilities.isIntegerType(dataType); final boolean isUnsigned = isInteger && ImageUtilities.isUnsignedType(sm); final boolean isRGB = isInteger && (bands.length == 3 || bands.length == 4); - final SampleDimension.Builder builder = new SampleDimension.Builder(); + final var builder = new SampleDimension.Builder(); for (int band=0; band < bands.length; band++) { double minimum = Double.NaN; double maximum = Double.NaN; @@ -398,7 +398,7 @@ abstract class RasterStore extends PRJDataStore implements GridCoverageResource if (band == VISIBLE_BAND) { try { if (isRGB) { - colorModel = new ColorModelBuilder().create(sm); + colorModel = new ColorModelBuilder().createRGB(sm); } else { colorModel = readColorMap(dataType, (int) (maximum + 1), bands.length); } @@ -451,7 +451,7 @@ abstract class RasterStore extends PRJDataStore implements GridCoverageResource final SampleDimension[] bands = range.select(sampleDimensions); Hashtable<String,Object> properties = null; if (stats != null) { - final Statistics[] as = new Statistics[range.getNumBands()]; + final var as = new Statistics[range.getNumBands()]; Arrays.fill(as, stats); properties = new Hashtable<>(); properties.put(PlanarImage.STATISTICS_KEY, as); diff --git a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/IndexedResourceBundle.java b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/IndexedResourceBundle.java index 71eda54802..1f5cd80eae 100644 --- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/IndexedResourceBundle.java +++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/IndexedResourceBundle.java @@ -239,6 +239,7 @@ public abstract class IndexedResourceBundle extends ResourceBundle implements Lo } } final String lineSeparator = System.lineSeparator(); + @SuppressWarnings("LocalVariableHidesMemberVariable") final String[] values = ensureLoaded(null); for (int i=0; i < values.length; i++) { final String key = keys [i]; @@ -268,6 +269,7 @@ public abstract class IndexedResourceBundle extends ResourceBundle implements Lo * @throws MissingResourceException if this method failed to load resources. */ private String[] ensureLoaded(final String key) throws MissingResourceException { + @SuppressWarnings("LocalVariableHidesMemberVariable") String[] values = this.values; if (values == null) synchronized (this) { values = this.values; @@ -353,6 +355,7 @@ public abstract class IndexedResourceBundle extends ResourceBundle implements Lo /* * Note: Synchronization is performed by 'ensureLoaded' */ + @SuppressWarnings("LocalVariableHidesMemberVariable") final String[] values = ensureLoaded(key); int keyID; try { @@ -766,6 +769,22 @@ public abstract class IndexedResourceBundle extends ResourceBundle implements Lo return null; } + /** + * Concatenates two sentences. The concatenation order is locale-sensitive. + * Current implementation ignores the locale and always concatenate the sentence from left to right. + * This method is defined for centralizing the places where such concatenations are done, for making + * easier to change this order if a future Apache SIS version supports right to left writing systems. + * + * @param first the first sentence, or {@code null} or empty. + * @param second the second sentence, or {@code null} or empty. + * @return the concatenated sentence. + */ + public static String concatenate(final String first, final String second) { + if (first == null || first.isBlank()) return second; + if (second == null || second.isBlank()) return first; + return first + ' ' + second; + } + /** * Returns a string representation of this object. * This method is for debugging purposes only.