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 d0147d6a96 When no color is specified for a category or a range of sample values, and provided that `Colorizer` is used for styling an existing image, preserve the existing colors. d0147d6a96 is described below commit d0147d6a9634b5ee11881fcf693dcd1d179caff4 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Sat Apr 1 18:35:42 2023 +0200 When no color is specified for a category or a range of sample values, and provided that `Colorizer` is used for styling an existing image, preserve the existing colors. --- .../apache/sis/gui/coverage/CoverageCanvas.java | 58 +++++++----- .../apache/sis/gui/coverage/CoverageControls.java | 2 +- .../apache/sis/gui/coverage/CoverageStyling.java | 18 ++-- .../apache/sis/internal/gui/control/ColorCell.java | 2 +- .../java/org/apache/sis/coverage/Category.java | 4 +- .../sis/coverage/grid/GridCoverageBuilder.java | 2 +- .../apache/sis/coverage/grid/ImageRenderer.java | 7 +- .../apache/sis/image/BandedSampleConverter.java | 2 +- .../main/java/org/apache/sis/image/Colorizer.java | 22 ++--- .../java/org/apache/sis/image/RecoloredImage.java | 3 +- .../java/org/apache/sis/image/Visualization.java | 13 ++- .../internal/coverage/j2d/ColorModelBuilder.java | 56 ++++++++--- .../internal/coverage/j2d/ColorModelFactory.java | 13 ++- .../sis/internal/coverage/j2d/ColorsForRange.java | 105 ++++++++++++++++----- .../coverage/j2d/ColorModelBuilderTest.java | 4 +- .../sis/internal/map/coverage/RenderingData.java | 2 +- .../java/org/apache/sis/measure/NumberRange.java | 7 +- .../main/java/org/apache/sis/measure/Range.java | 23 ++++- .../java/org/apache/sis/measure/RangeTest.java | 17 +++- 19 files changed, 248 insertions(+), 112 deletions(-) diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java index 735b28b475..5cfe75de75 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java @@ -21,7 +21,6 @@ import java.util.EnumMap; import java.util.List; import java.util.Locale; import java.util.concurrent.Future; -import java.util.function.Function; import java.util.logging.LogRecord; import java.io.IOException; import java.awt.Graphics2D; @@ -54,7 +53,6 @@ import org.apache.sis.referencing.CommonCRS; import org.apache.sis.referencing.operation.transform.MathTransforms; import org.apache.sis.referencing.operation.transform.LinearTransform; import org.apache.sis.referencing.operation.matrix.AffineTransforms2D; -import org.apache.sis.coverage.Category; import org.apache.sis.coverage.SampleDimension; import org.apache.sis.coverage.SubspaceNotSpecifiedException; import org.apache.sis.coverage.grid.GridCoverage; @@ -62,6 +60,7 @@ import org.apache.sis.coverage.grid.GridExtent; import org.apache.sis.coverage.grid.GridGeometry; import org.apache.sis.geometry.Envelope2D; import org.apache.sis.geometry.Shapes2D; +import org.apache.sis.image.Colorizer; import org.apache.sis.image.PlanarImage; import org.apache.sis.image.Interpolation; import org.apache.sis.storage.GridCoverageResource; @@ -407,21 +406,44 @@ public class CoverageCanvas extends MapCanvasAWT { * @see #interpolationProperty */ public final void setInterpolation(final Interpolation interpolation) { - assert Platform.isFxApplicationThread(); interpolationProperty.set(interpolation); } /** - * Sets the colors to use for given categories in image. Invoking this method causes a repaint event, - * so it should be invoked only if at least one color is known to have changed. + * Sets the colorization algorithm to apply on rendered images. + * Should be an algorithm based on coverage categories. * - * @param colors colors to use for arbitrary categories of sample values, or {@code null} for default. + * <p>{@code CoverageCanvas} can not detect when the given colorizer changes its internal state. + * The {@link #stylingChanged()} method should be invoked explicitly when such change occurs.</p> + * + * @param colorizer colorization algorithm to apply on computed image, or {@code null} for default. */ - final void setCategoryColors(final Function<Category, java.awt.Color[]> colors) { + final void setColorizer(final Colorizer colors) { + data.processor.setColorizer(colors); + stylingChanged(); + } + + /** + * Invoked by {@link CoverageControls} when the user selected a new color stretching mode. + * The sample values are assumed the same, only the image appearance is modified. + */ + final void setStretching(final Stretching selection) { if (TRACE) { - trace("setCategoryColors(Function): causes repaint."); + trace("setStretching(%s)", selection); } - data.processor.setCategoryColors(colors); + if (data.selectedDerivative != selection) { + data.selectedDerivative = selection; + stylingChanged(); + } + } + + /** + * Invoked when image colors changed. Derived features such are isolines are assumed unchanged. + * This method should be invoked explicitly when the {@link Colorizer} changes its internal state. + * + * @see #clearRenderedImage() + */ + final void stylingChanged() { resampledImage = null; requestRepaint(); } @@ -701,8 +723,7 @@ public class CoverageCanvas extends MapCanvasAWT { trace("onInterpolationSpecified(%s)", newValue); } data.processor.setInterpolation(newValue); - resampledImage = null; - requestRepaint(); + stylingChanged(); } /** @@ -1114,21 +1135,6 @@ public class CoverageCanvas extends MapCanvasAWT { return Shapes2D.transform(MathTransforms.bidimensional(getObjectiveToDisplay().inverse()), displayBounds, null); } - /** - * Invoked by {@link CoverageControls} when the user selected a new color stretching mode. - * The sample values are assumed the same; only the image appearance is modified. - */ - final void setStyling(final Stretching selection) { - if (TRACE) { - trace("setStyling(%s)", selection); - } - if (data.selectedDerivative != selection) { - data.selectedDerivative = selection; - resampledImage = null; - requestRepaint(); - } - } - /** * Invoked when an exception occurred while computing a transform but the painting process can continue. */ diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java index 48e6437384..719edb2fb2 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java @@ -122,7 +122,7 @@ final class CoverageControls extends ViewAndControls { * - Color stretching */ interpolation = InterpolationConverter.button(view); - stretching = Stretching.createButton((p,o,n) -> view.setStyling(n)); + stretching = Stretching.createButton((p,o,n) -> view.setStretching(n)); final GridPane valuesControl = Styles.createControlGrid(0, label(vocabulary, Vocabulary.Keys.Interpolation, interpolation), label(vocabulary, Vocabulary.Keys.Stretching, stretching)); diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageStyling.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageStyling.java index 987f588f7a..07fa93f7e1 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageStyling.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageStyling.java @@ -32,6 +32,7 @@ import javafx.beans.value.ObservableValue; import javafx.scene.control.ContextMenu; import org.opengis.util.InternationalString; import org.apache.sis.coverage.Category; +import org.apache.sis.image.Colorizer; import org.apache.sis.internal.gui.Resources; import org.apache.sis.internal.gui.ImmutableObjectProperty; import org.apache.sis.internal.gui.control.ColorRamp; @@ -53,7 +54,7 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func /** * Customized colors selected by user. Keys are English names of categories. * - * @see #key(Category) + * @see #apply(Category) */ private final Map<String,ColorRamp> customizedColors; @@ -65,9 +66,13 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func /** * Creates a new styling instance. */ + @SuppressWarnings("ThisEscapedInObjectConstruction") CoverageStyling(final CoverageCanvas canvas) { customizedColors = new HashMap<>(); this.canvas = canvas; + if (canvas != null) { + canvas.setColorizer(Colorizer.forCategories(this)); + } } /** @@ -77,7 +82,7 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func final void copyStyling(final CoverageStyling source) { customizedColors.putAll(source.customizedColors); if (canvas != null) { - canvas.setCategoryColors(customizedColors.isEmpty() ? null : this); + canvas.stylingChanged(); } } @@ -92,7 +97,7 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func customizedColors.clear(); items.setAll(content); if (canvas != null) { - canvas.setCategoryColors(null); + canvas.stylingChanged(); } } @@ -146,8 +151,8 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func } /** - * Associates colors to the given category. - * This is invoked when users confirmed that (s)he wants to use the selected colors. + * Associates colors to the given category. This method is invoked when new categories are shown + * in table column managed by this {@code CoverageStyling}, and when user selects new colors. * * @param category the category for which to assign new color(s). * @param colors the new color for the given category, or {@code null} for resetting default value. @@ -163,8 +168,7 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func old = customizedColors.remove(key); } if (canvas != null && !Objects.equals(colors, old)) { - canvas.setCategoryColors(customizedColors.isEmpty() ? null : this); - // Above method call causes a repaint event even if value is the same. + canvas.stylingChanged(); } return category.isQuantitative() ? ColorRamp.Type.GRADIENT : ColorRamp.Type.SOLID; } diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java index 87e3df3fe1..c4a206d230 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java @@ -292,7 +292,7 @@ final class ColorCell<S> extends TableCell<S,ColorRamp> implements EventHandler< if (row != null) { final S item = row.getItem(); if (item != null) { - type = handler.applyColors(item, colors); + type = handler.applyColors(item, (colors != ColorRamp.DEFAULT) ? colors : null); } } } diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/Category.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/Category.java index 59efc35e42..df3901ef6c 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/coverage/Category.java +++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/Category.java @@ -179,8 +179,8 @@ public class Category implements Serializable { * Creates a copy of the given category except for the {@link #converse} and {@link #toConverse} fields. * This constructor serves two purposes: * <ul> - * <li>If {@code caller} is null, then {@link #toConverse} is is set to identity. - * This is used only if a user specify a {@code ConvertedCategory} to {@link SampleDimension} constructor. + * <li>If {@code caller} is null, then {@link #toConverse} is set to identity. + * This is used only if a user specifies a {@code ConvertedCategory} to {@link SampleDimension} constructor. * Such converted category can only come from another {@code SampleDimension} and may have inconsistent * information for the new sample dimension that the user is creating.</li> * <li>If {@code caller} is non-null, then {@link #toConverse} is set to the same transform than {@code copy} and 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 15a52e41ad..5e46bc3419 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 @@ -470,7 +470,7 @@ public class GridCoverageBuilder { */ bands = GridCoverage2D.defaultIfAbsent(bands, null, raster.getNumBands()); final SampleModel sm = raster.getSampleModel(); - final ColorModelBuilder colorizer = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE); + final ColorModelBuilder colorizer = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE, null); final ColorModel colors; if (colorizer.initialize(sm, bands.get(visibleBand)) || colorizer.initialize(sm, visibleBand)) { colors = colorizer.createColorModel(ImageUtilities.getBandType(sm), bands.size(), visibleBand); 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 31545550ea..1d5bd9e17e 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 @@ -661,8 +661,9 @@ public class ImageRenderer { /** * Specifies the colors to apply for each category in a sample dimension. - * The given function can return {@code null}, which means transparent. - * If this method is never invoked, then the default is a grayscale for + * The given function can return {@code null} for unrecognized categories. + * If this method is never invoked, or if a category is unrecognized, + * then the default is a grayscale for * {@linkplain Category#isQuantitative() quantitative categories} and * transparent for qualitative categories (typically "no data" values). * @@ -752,7 +753,7 @@ public class ImageRenderer { @SuppressWarnings("UseOfObsoleteCollectionType") public RenderedImage createImage() { final Raster raster = createRaster(); - final ColorModelBuilder colorizer = new ColorModelBuilder(colors); + final ColorModelBuilder colorizer = new ColorModelBuilder(colors, null); final ColorModel colors; final SampleModel sm = raster.getSampleModel(); if (colorizer.initialize(sm, bands[visibleBand]) || colorizer.initialize(sm, visibleBand)) { 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 8cc2527aa5..3ba4de609c 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 @@ -238,7 +238,7 @@ class BandedSampleConverter extends ComputedImage { if (sampleDimensions != null && visibleBand >= 0 && visibleBand < sampleDimensions.length) { sd = sampleDimensions[visibleBand]; } - final var builder = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE); + final var builder = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE, null); if (builder.initialize(source.getSampleModel(), sd) || builder.initialize(source.getColorModel())) { 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 8888913868..f926f3e84e 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 @@ -191,18 +191,19 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode * this colorizer creates a non-standard (and potentially slow) color model.</p> * * <h4>Default colors</h4> - * The {@code colors} map shall not be null or empty but may contain {@code null} values. - * Those null values are translated to default sets of colors in an implementation dependent way. + * The given {@code colors} map can associate to some keys a null or an empty color arrays. + * An empty array (i.e. no color) is interpreted as an explicit request for transparency. + * But null values are interpreted as unspecified colors, + * in which case the defaults are implementation dependent. * In current implementation, the defaults are: * * <ul> - * <li>If the range minimum and maximum values are not equal, default to grayscale colors.</li> + * <li>If this colorizer is used for {@linkplain ImageProcessor#visualize(RenderedImage) visualization}, + * try to keep the existing colors of the image to visualize.</li> + * <li>Otherwise if the range minimum and maximum values are not equal, default to grayscale colors.</li> * <li>Otherwise default to a fully transparent color.</li> * </ul> * - * Those defaults may change in any future Apache SIS version. - * For example a future version may first tries to preserve the existing colors of an image. - * * <h4>Limitations</h4> * In current implementation, the non-standard color model ignores the specified colors. * If the image data type is not 8 or 16 bits integer, the colors are always grayscale. @@ -247,14 +248,13 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode * In current implementation, the defaults are: * * <ul> - * <li>If all categories are unrecognized, then the colorizer returns an empty value.</li> + * <li>If this colorizer is used for {@linkplain ImageProcessor#visualize(RenderedImage) visualization}, + * try to keep the existing colors of the image to visualize.</li> + * <li>Otherwise if all categories are unrecognized, then the colorizer returns an empty value.</li> * <li>Otherwise, {@linkplain Category#isQuantitative() quantitative} categories default to grayscale colors.</li> * <li>Otherwise qualitative categories default to a fully transparent color.</li> * </ul> * - * Those defaults may change in any future Apache SIS version. - * For example a future version may first tries to preserve the existing colors of an image. - * * <h4>Conditions</h4> * This colorizer is used when {@link Target#getRanges()} provides a non-empty value. * That value is typically fetched from the {@value PlanarImage#SAMPLE_DIMENSIONS_KEY} image property, @@ -280,7 +280,7 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode final List<SampleDimension> ranges = target.getRanges().orElse(null); if (visibleBand < ranges.size()) { final SampleModel model = target.getSampleModel(); - final var c = new ColorModelBuilder(colors); + final var c = new ColorModelBuilder(colors, null); 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/RecoloredImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java index ce24f13234..564d45b88f 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 @@ -304,8 +304,7 @@ final class RecoloredImage extends ImageAdapter { Arrays.fill(ARGB, end+1, validMax+1, icm.getRGB(validMax)); final float scale = (float) ((validMax - validMin) / (maximum - minimum)); for (int i = start; i <= end; i++) { - final float s = (i - start) * scale + validMin; - ARGB[i] = icm.getRGB(Math.round(s)); + ARGB[i] = icm.getRGB(Math.round((i - start) * scale) + validMin); } final SampleModel sm = source.getSampleModel(); cm = ColorModelFactory.createIndexColorModel(sm.getNumBands(), visibleBand, ARGB, icm.hasAlpha(), icm.getTransparentPixel()); 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 d83417ecba..ed6313022e 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 @@ -287,6 +287,7 @@ final class Visualization extends ResampledImage { if (colorizer != null) { colorModel = colorizer.apply(target).orElse(null); } + final ColorModel sourceCM = coloredSource.getColorModel(); /* * Get a `ColorModelBuilder` which will compute the `ColorModel` of destination image. * There is different ways to setup the builder, depending on which `Colorizer` is used. @@ -300,15 +301,14 @@ final class Visualization extends ResampledImage { final ColorModelBuilder builder; final var rangeColors = target.rangeColors; if (rangeColors != null && !rangeColors.isEmpty()) { - builder = new ColorModelBuilder(rangeColors.entrySet()); + builder = new ColorModelBuilder(rangeColors.entrySet(), sourceCM); initialized = true; } else { /* * Ranges of sample values were not specified explicitly. Instead, we will try to infer them * in various ways: sample dimensions, scaled color model, or image statistics in last resort. */ - builder = new ColorModelBuilder(target.categoryColors); - final ColorModel colorModel = coloredSource.getColorModel(); + builder = new ColorModelBuilder(target.categoryColors, sourceCM); initialized = builder.initialize(coloredSource.getSampleModel(), visibleSD); if (initialized) { /* @@ -317,7 +317,7 @@ final class Visualization extends ResampledImage { * determined by the SampleModel, then user enhanced contrast by a call to `stretchColorRamp(…)`. * We want to preserve that contrast enhancement. */ - builder.rescaleMainRange(colorModel); + builder.rescaleMainRange(sourceCM); } else { /* * At this point there is no more user-supplied colors (through `Colorizer`) that we can use. @@ -325,7 +325,7 @@ final class Visualization extends ResampledImage { * There is no call to `rescaleMainRange(…)` because the following code already uses the range * specified by the ColorModel, if available. */ - initialized = builder.initialize(colorModel); + initialized = builder.initialize(sourceCM); if (!initialized) { if (coloredSource instanceof RecoloredImage) { final RecoloredImage colored = (RecoloredImage) coloredSource; @@ -353,6 +353,9 @@ final class Visualization extends ResampledImage { builder.getSampleToIndexValues() // Must be after `compactColorModel(…)`. }; if (shortcut) { + if (converters[0].isIdentity() && colorModel.equals(sourceCM)) { + return coloredSource; + } interpolation = Interpolation.NEAREST; } else { interpolation = combine(interpolation.toCompatible(source), converters); diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilder.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilder.java index 455899fa16..2243187865 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilder.java +++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilder.java @@ -51,8 +51,9 @@ import org.apache.sis.util.resources.Vocabulary; * <ol> * <li>Create a new {@link ColorModelBuilder} instance.</li> * <li>Invoke one of {@code initialize(…)} methods.</li> - * <li>Invoke {@link #createColorModel(int, int, int)}.</li> - * <li>Discards {@code ColorModelBuilder}; each instance should be used only once.</li> + * <li>Invoke {@link #createColorModel(int, int, int)} or {@link #compactColorModel(int, int)}.</li> + * <li>Invoke {@link #getSampleToIndexValues()} if this auxiliary information is useful.</li> + * <li>Discards {@code ColorModelBuilder}. Each instance shall be used only once.</li> * </ol> * * There is no {@code initialize(Raster)} or {@code initialize(RenderedImage)} method because if those methods @@ -126,8 +127,8 @@ public final class ColorModelBuilder { /** * The colors to use for each range of values in the source image. - * Entries will be sorted and modified in place. - * The array may be null if unspecified, but shall not contain null element. + * This array is initially null and created by an {@code initialize(…)} method. + * After initialization, this array shall not contain null element. */ private ColorsForRange[] entries; @@ -146,14 +147,26 @@ public final class ColorModelBuilder { * <p>This sample dimension should not be returned to the user because it may not contain meaningful values. * For example, it may contain an "artificial" transfer function for computing a {@link MathTransform1D} from * source range to the [0 … 255] value range.</p> + * + * @see #getSampleToIndexValues() */ private SampleDimension target; /** - * Default range of values to use if no explicitly specified by a {@link Category}. + * Default range of values to use if not explicitly specified by a {@link Category}. */ private NumberRange<?> defaultRange; + /** + * Colors to inherit if a range of values is undefined, or {@code null} if none. + * This field should be non-null only when this builder is used for styling an image before visualization. + * This field should be null when this builder is created for creating a new image because the meaning of + * pixel values may be completely different (i.e. meaning of {@linkplain #source} may not be applicable). + * + * @see ColorsForRange#isUndefined() + */ + private final ColorModel inheritedColors; + /** * Creates a new colorizer which will apply colors on the given range of values in source image. * The {@code ColorModelBuilder} is considered initialized after this constructor; @@ -164,11 +177,14 @@ public final class ColorModelBuilder { * and to grayscale colors otherwise. * Empty arrays of colors are interpreted as explicitly transparent.</p> * - * @param colors the colors to use for each range of values in source image. + * @param colors the colors to use for each range of values in source image. + * @param inherited the colors to use as fallback if some ranges have undefined colors, or {@code null}. + * Should be non-null only for styling an exiting image before visualization. */ - public ColorModelBuilder(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors) { + public ColorModelBuilder(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors, final ColorModel inherited) { ArgumentChecks.ensureNonEmpty("colors", colors); - entries = ColorsForRange.list(colors); + entries = ColorsForRange.list(colors, inherited); + inheritedColors = inherited; this.colors = GRAYSCALE; } @@ -176,11 +192,19 @@ public final class ColorModelBuilder { * Creates a new colorizer which will use the given function for determining the colors to apply. * Callers need to invoke an {@code initialize(…)} method after this constructor. * - * @param colors the colors to use for each category, or {@code null} for default. - * The function may return {@code null} for unrecognized categories. + * <p>The {@code inherited} parameter is non-null when this builder is created for styling + * an existing image before visualization. This parameter should be null when this builder + * is created for creating a new image, even when that new image is derived from a source, + * because the meaning of pixel values may be completely different.</p> + * + * @param colors the colors to use for each category, or {@code null} for default. + * The function may return {@code null} for unrecognized categories. + * @param inherited the colors to use as fallback for unrecognized categories, or {@code null}. + * Should be non-null only for styling an exiting image before visualization. */ - public ColorModelBuilder(final Function<Category,Color[]> colors) { + public ColorModelBuilder(final Function<Category,Color[]> colors, final ColorModel inherited) { this.colors = (colors != null) ? colors : GRAYSCALE; + inheritedColors = inherited; } /** @@ -222,7 +246,7 @@ public final class ColorModelBuilder { boolean missingNodata = true; ColorsForRange[] entries = new ColorsForRange[categories.size()]; for (int i=0; i<entries.length; i++) { - final var range = new ColorsForRange(categories.get(i), colors); + final var range = new ColorsForRange(categories.get(i), colors, inheritedColors); isUndefined &= range.isUndefined(); missingNodata &= range.isData; entries[i] = range; @@ -236,7 +260,7 @@ public final class ColorModelBuilder { final int count = entries.length; entries = Arrays.copyOf(entries, count + 1); entries[count] = new ColorsForRange(TRANSPARENT, - NumberRange.create(Float.class, Float.NaN), null, false); + NumberRange.create(Float.class, Float.NaN), null, false, inheritedColors); } // Leave `target` to null. It will be computed by `compact()` if needed. this.entries = entries; @@ -351,7 +375,9 @@ public final class ColorModelBuilder { final ColorsForRange[] entries = new ColorsForRange[categories.size()]; for (int i=0; i<entries.length; i++) { final Category category = categories.get(i); - entries[i] = new ColorsForRange(category, colors); + final var range = new ColorsForRange(category.forConvertedValues(true), colors, inheritedColors); + range.sampleRange = category.getSampleRange(); + entries[i] = range; } this.entries = entries; } @@ -530,7 +556,7 @@ reuse: if (source != null) { span += sourceRange.getSpan(); final ColorsForRange[] tmp = Arrays.copyOf(entries, ++count); System.arraycopy(entries, deferred, tmp, ++deferred, count - deferred); - tmp[deferred-1] = new ColorsForRange(null, sourceRange, null, true); + tmp[deferred-1] = new ColorsForRange(null, sourceRange, null, true, null); entries = tmp; } } diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelFactory.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelFactory.java index 510fa6cd61..69410a46a4 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelFactory.java +++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelFactory.java @@ -209,7 +209,7 @@ public final class ColorModelFactory { } } } - codes [ count] = entry.toARGB(); + codes [ count] = entry.toARGB(upper - lower); starts[ count] = lower; starts[++count] = upper; } @@ -309,7 +309,8 @@ public final class ColorModelFactory { public static ColorModelFactory piecewise(final Map<NumberRange<?>, Color[]> colors) { final var entries = colors.entrySet(); ArgumentChecks.ensureNonEmpty("colors", entries); - return PIECEWISES.intern(new ColorModelFactory(DataBuffer.TYPE_BYTE, 0, DEFAULT_VISIBLE_BAND, ColorsForRange.list(entries))); + final var ranges = ColorsForRange.list(entries, null); + return PIECEWISES.intern(new ColorModelFactory(DataBuffer.TYPE_BYTE, 0, DEFAULT_VISIBLE_BAND, ranges)); } /** @@ -479,7 +480,7 @@ public final class ColorModelFactory { { ArgumentChecks.ensureNonEmpty("colors", colors); return createPiecewise(dataType, numBands, visibleBand, new ColorsForRange[] { - new ColorsForRange(null, new NumberRange<>(Double.class, lower, true, upper, false), colors, true) + new ColorsForRange(null, new NumberRange<>(Double.class, lower, true, upper, false), colors, true, null) }); } @@ -779,7 +780,7 @@ public final class ColorModelFactory { /** * Copies {@code colors} into {@code ARGB} array from index {@code lower} inclusive to index {@code upper} exclusive. * If {@code upper-lower} is not equal to the length of {@code colors} array, then colors will be interpolated. - * The given {@code colors} array must be initialized with zero values in the {@code lower} … {@code upper} range. + * The given {@code ARGB} array must be initialized with zero values in the {@code lower} … {@code upper} range. * * @param colors colors to copy into the {@code ARGB} array. * @param ARGB array of integer to write ARGB values into. @@ -796,6 +797,10 @@ public final class ColorModelFactory { case 1: ARGB[lower] = colors[0]; // fall through case 0: return; } + if (upper - lower == colors.length) { + System.arraycopy(colors, 0, ARGB, 0, colors.length); + return; + } /* * Prepares the coefficients for the iteration. * The non-final ones will be updated inside the loop. diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorsForRange.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorsForRange.java index 31894df212..76dc35f90a 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorsForRange.java +++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorsForRange.java @@ -21,6 +21,7 @@ import java.util.Collection; import java.util.Objects; import java.util.function.Function; import java.awt.Color; +import java.awt.image.ColorModel; import java.awt.image.IndexColorModel; import org.apache.sis.coverage.Category; import org.apache.sis.measure.NumberRange; @@ -53,6 +54,13 @@ final class ColorsForRange implements Comparable<ColorsForRange> { */ NumberRange<?> sampleRange; + /** + * The range of sample values as originally specified. + * Contrarily to {@link #sampleRange}, this range will not be modified by {@code compact()}. + * This is used for fetching colors from {@link #inheritedColors} if {@link #colors} is null. + */ + private final NumberRange<?> originalSampleRange; + /** * The colors to apply on the range of sample values. * An empty array means that the category is explicitly specified as transparent. @@ -60,10 +68,22 @@ final class ColorsForRange implements Comparable<ColorsForRange> { * is grayscale for quantitative category and transparent for qualitative category. * * @see #isUndefined() - * @see #toARGB() + * @see #toARGB(int) */ private final Color[] colors; + /** + * The original colors, or {@code null} if unspecified. + * This is used as a fallback if {@link #colors} is null. + * This field should be non-null only when this {@code ColorsForRange} is created for + * styling an image before visualization. It should be null when creating a new image, + * because the meaning of pixel values (i.e. the sample dimensions) may be different. + * + * @see #originalSampleRange + * @see ColorModelBuilder#inheritedColors + */ + private final ColorModel inheritedColors; + /** * {@code true} if this entry should be taken as data, or {@code false} if it should be ignored. * Entry to ignore are entries associated to NaN values. @@ -73,57 +93,71 @@ final class ColorsForRange implements Comparable<ColorsForRange> { /** * Creates a new instance for the given category. * - * @param category the category for which this {@code ColorsForRange} is created, or {@code null}. - * @param colors colors to apply on the category. + * @param category the category for which this {@code ColorsForRange} is created. + * @param colors colors to apply on the category. + * @param inherited the original colors to use as fallback, or {@code null} if none. + * Should be non-null only for styling an exiting image before visualization. */ - ColorsForRange(final Category category, final Function<Category,Color[]> colors) { - final CharSequence name = category.getName(); - this.name = (name != null) ? name : sampleRange.toString(); + ColorsForRange(final Category category, final Function<Category,Color[]> colors, final ColorModel inherited) { + this.name = category.getName(); this.sampleRange = category.getSampleRange(); - this.colors = colors.apply(category); this.isData = category.isQuantitative(); + this.colors = colors.apply(category); + inheritedColors = inherited; + originalSampleRange = sampleRange; } /** * Creates a new instance for the given range of values. * - * @param name a name identifying the range of values, or {@code null} for automatic. - * @param sampleRange range of sample values on which the colors will be applied. - * @param colors colors to apply on the range of sample values, or {@code null} for default. - * @param isData whether this entry should be taken as main data (not fill values). + * @param name a name identifying the range of values, or {@code null} for automatic. + * @param sampleRange range of sample values on which the colors will be applied. + * @param colors colors to apply on the range of sample values, or {@code null} for default. + * @param isData whether this entry should be taken as main data (not fill values). + * @param inherited the original colors to use as fallback, or {@code null} if none. + * Should be non-null only for styling an exiting image before visualization. */ - ColorsForRange(final CharSequence name, final NumberRange<?> sampleRange, final Color[] colors, final boolean isData) { + ColorsForRange(final CharSequence name, final NumberRange<?> sampleRange, final Color[] colors, + final boolean isData, final ColorModel inherited) + { ArgumentChecks.ensureNonNull("sampleRange", sampleRange); this.name = (name != null) ? name : sampleRange.toString(); - this.sampleRange = sampleRange; - this.colors = colors; + this.sampleRange = originalSampleRange = sampleRange; this.isData = isData; + this.colors = colors; + inheritedColors = inherited; } /** * Returns {@code true} if no color has been specified for this range. * Note that "undefined" is not the same as fully transparent color. + * + * <p>If no colors were explicitly defined but a fallback exists, then this method considers + * this range as defined for allowing {@link ColorModelBuilder} to inherit those colors with + * the range of values specified by {@link #originalSampleRange}. We conceptually accept any + * {@link #inheritedColors} even if {@link #toARGB(int)} can not handle all of them.</p> */ final boolean isUndefined() { - return colors == null; + return colors == null && inheritedColors == null; } /** * Converts {@linkplain Map#entrySet() map entries} to an array of {@code ColorsForRange} entries. * The {@link #category} of each entry is left to null. * - * @param colors the colors to use for each range of sample values. - * A {@code null} entry value means transparent. + * @param colors the colors to use for each range of sample values. + * A {@code null} entry value means transparent. + * @param inherited the original color model from which to inherit undefined colors, or {@code null} if none. * @return colors to use for each range of values in the source image. * Never null and does not contain null elements. */ - static ColorsForRange[] list(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors) { + static ColorsForRange[] list(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors, final ColorModel inherited) { final ColorsForRange[] entries = new ColorsForRange[colors.size()]; int n = 0; for (final Map.Entry<NumberRange<?>,Color[]> entry : colors) { final NumberRange<?> range = entry.getKey(); boolean singleton = Objects.equals(range.getMinValue(), range.getMaxValue()); - entries[n++] = new ColorsForRange(null, range, entry.getValue(), !singleton); + entries[n++] = new ColorsForRange(null, range, entry.getValue(), !singleton, inherited); } return ArraysExt.resize(entries, n); // `resize` should not be needed, but we are paranoiac. } @@ -134,7 +168,7 @@ final class ColorsForRange implements Comparable<ColorsForRange> { @Override public String toString() { final StringBuilder buffer = new StringBuilder(name).append(": ").append(sampleRange); - appendColorRange(buffer, toARGB()); + appendColorRange(buffer, toARGB(2)); return buffer.toString(); } @@ -197,9 +231,10 @@ final class ColorsForRange implements Comparable<ColorsForRange> { * Returns the ARGB codes for the colors. * If all colors are transparent, returns an empty array. * - * @return ARGB codes for the given colors. Never {@code null} but may be empty. + * @param length desired array length. This is only a hint and may be ignored. + * @return ARGB codes for this color ramp. Never {@code null} but may be empty. */ - final int[] toARGB() { + final int[] toARGB(final int length) { if (colors != null) { int combined = 0; final int[] ARGB = new int[colors.length]; @@ -214,6 +249,32 @@ final class ColorsForRange implements Comparable<ColorsForRange> { if ((combined & 0xFF000000) != 0) { return ARGB; } + } else if (!originalSampleRange.isEmpty() && inheritedColors instanceof IndexColorModel) { + /* + * If colors are undefined, try to inherit them from the original colors. + * If the number of available colors is larger than the desired number, + * this block returns a subset of the inherited colors. + */ + final IndexColorModel icm = (IndexColorModel) inheritedColors; + int offset = Math.round((float) originalSampleRange.getMinDouble(true)); + int numSrc = Math.round((float) originalSampleRange.getMaxDouble()) - offset; + if (originalSampleRange.isMinIncluded()) numSrc++; + final int[] ARGB; + if (numSrc <= length) { + ARGB = new int[numSrc]; + if (offset == 0 && numSrc == icm.getMapSize()) { + icm.getRGBs(ARGB); + } else for (int i=0; i<numSrc; i++) { + ARGB[i] = icm.getRGB(i + offset); + } + } else { + ARGB = new int[length]; + final float scale = ((float) (numSrc-1)) / (length-1); + for (int i=0; i<length; i++) { + ARGB[i] = icm.getRGB(Math.round(i * scale) + offset); + } + } + return ARGB; } else if (isData) { return new int[] {0xFF000000, 0xFFFFFFFF}; } diff --git a/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilderTest.java b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilderTest.java index 66bb12b931..012e1dcae6 100644 --- a/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilderTest.java +++ b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilderTest.java @@ -52,7 +52,7 @@ public final class ColorModelBuilderTest extends TestCase { final ColorModelBuilder colorizer = new ColorModelBuilder(List.of( new SimpleEntry<>(NumberRange.create(0, true, 0, true), new Color[] {Color.GRAY}), new SimpleEntry<>(NumberRange.create(1, true, 1, true), new Color[] {ColorModelFactory.TRANSPARENT}), - new SimpleEntry<>(NumberRange.create(2, true, 15, true), new Color[] {Color.BLUE, Color.WHITE, Color.RED}))); + new SimpleEntry<>(NumberRange.create(2, true, 15, true), new Color[] {Color.BLUE, Color.WHITE, Color.RED})), null); /* * No conversion of sample values should be necessary because the * above-given ranges already fit in a 4-bits IndexColormodel. @@ -99,7 +99,7 @@ public final class ColorModelBuilderTest extends TestCase { .addQualitative ("Error", MathFunctions.toNanFloat(3)) .setName("Temperature").build(); - final ColorModelBuilder colorizer = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE); + final ColorModelBuilder colorizer = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE, null); assertTrue("initialize", colorizer.initialize(null, sd)); final IndexColorModel cm = (IndexColorModel) colorizer.compactColorModel(1, 0); // Must be first. /* 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 6c51dd0946..0b5f9efe5b 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 @@ -662,7 +662,7 @@ public class RenderingData implements Cloneable { */ if (CREATE_INDEX_COLOR_MODEL) { final ColorModelType ct = ColorModelType.find(recoloredImage.getColorModel()); - if (ct.isSlow || (ct.useColorRamp && processor.getColorizer() != null)) try { + if (ct.isSlow || ct.useColorRamp) try { SampleDimensions.IMAGE_PROCESSOR_ARGUMENT.set(dataRanges); return processor.visualize(recoloredImage, bounds, displayToCenter); } finally { diff --git a/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java b/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java index 80f72e2717..5be9072aa1 100644 --- a/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java +++ b/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java @@ -108,11 +108,10 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range /** * Returns a unique instance of the given range, except if the range is empty. * - * <div class="note"><b>Rational:</b> - * we exclude empty ranges because the {@link Range#equals(Object)} consider them as equal. + * <h4>Rational</h4> + * We exclude empty ranges because the {@link Range#equals(Object)} consider them as equal. * Consequently, if empty ranges were included in the pool, this method would return in some * occasions an empty range with different values than the given {@code range} argument. - * </div> * * We use this method only for caching range of wrapper of primitive types ({@link Byte}, * {@link Short}, <i>etc.</i>) because those types are known to be immutable. @@ -645,7 +644,7 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range /** * Computes the difference between minimum and maximum values. If numbers are integers, the difference is computed - * using inclusive values (e.g. equivalent to <code>{@linkplain #getMinDouble(boolean) getMinDouble}(true)</code>). + * using inclusive values (e.g. using <code>{@linkplain #getMinDouble(boolean) getMinDouble}(true)</code>). * Otherwise the minimum and maximum values are used as-is * (because making them inclusive is considered an infinitely small change). * diff --git a/core/sis-utility/src/main/java/org/apache/sis/measure/Range.java b/core/sis-utility/src/main/java/org/apache/sis/measure/Range.java index 6e63f3f36b..ffe769cf1b 100644 --- a/core/sis-utility/src/main/java/org/apache/sis/measure/Range.java +++ b/core/sis-utility/src/main/java/org/apache/sis/measure/Range.java @@ -81,7 +81,7 @@ import org.apache.sis.util.Numbers; * @author Joe White * @author Martin Desruisseaux (Geomatys) * @author Jody Garnett (for parameterized type inspiration) - * @version 1.0 + * @version 1.4 * * @param <E> the type of range elements, typically a {@link Number} subclass or {@link java.util.Date}. * @@ -269,7 +269,7 @@ public class Range<E extends Comparable<? super E>> implements CheckedContainer< * Returns {@code true} if this range is empty. A range is empty if the * {@linkplain #getMinValue() minimum value} is greater than the * {@linkplain #getMaxValue() maximum value}, or if they are equal while - * at least one of them is exclusive. + * at least one of them is exclusive, or if both bounds are NaN. * * <h4>API note</h4> * This method is final because often used by the internal implementation. @@ -286,8 +286,25 @@ public class Range<E extends Comparable<? super E>> implements CheckedContainer< if (c < 0) { return false; // Minimum is smaller than maximum. } + if (c != 0) { // Minimum is NaN or greater than maximum. + return !isNaN(minValue); + } // If min and max are equal, then the range is empty if at least one of them is exclusive. - return (c != 0) || !isMinIncluded || !isMaxIncluded; + if (!isMinIncluded || !isMaxIncluded) { + return true; + } + return isNaN(minValue); // At this point if min is NaN, max is also NaN. + } + + /** + * Returns {@code true} if the given value is NaN. This method tests only the primitive wrappers + * because the behavior of their {@code compareTo(…)} method is clearly documented. Calls to this + * method assume that NaNs are considered by {@code compareTo(…)} as greater than all other values. + */ + private static boolean isNaN(final Object value) { + if (value instanceof Double) return ((Double) value).isNaN(); + if (value instanceof Float) return ((Float) value).isNaN(); + return false; } /** diff --git a/core/sis-utility/src/test/java/org/apache/sis/measure/RangeTest.java b/core/sis-utility/src/test/java/org/apache/sis/measure/RangeTest.java index f2b2750853..d266990144 100644 --- a/core/sis-utility/src/test/java/org/apache/sis/measure/RangeTest.java +++ b/core/sis-utility/src/test/java/org/apache/sis/measure/RangeTest.java @@ -30,7 +30,7 @@ import static org.apache.sis.test.Assert.*; * * @author Joe White * @author Martin Desruisseaux (Geomatys) - * @version 1.1 + * @version 1.4 * @since 0.3 */ public final class RangeTest extends TestCase { @@ -103,6 +103,21 @@ public final class RangeTest extends TestCase { new Range(String.class, 123.233, true, 8740.09, true); } + /** + * Tests {@link Range#isEmpty()}. + */ + @Test + public void testIsEmpty() { + assertFalse(new Range<>(Float.class, 3f, true, 5f, true).isEmpty()); + assertFalse(new Range<>(Float.class, 3f, true, 3f, true).isEmpty()); + assertTrue (new Range<>(Float.class, 3f, true, 3f, false).isEmpty()); + assertTrue (new Range<>(Float.class, 3f, false, 3f, true).isEmpty()); + assertTrue (new Range<>(Float.class, 3f, false, 3f, false).isEmpty()); + assertFalse(new Range<>(Float.class, Float.NaN, true, 5f, true).isEmpty()); + assertFalse(new Range<>(Float.class, 3f, true, Float.NaN, true).isEmpty()); + assertTrue (new Range<>(Float.class, Float.NaN, true, Float.NaN, true).isEmpty()); + } + /** * Tests the {@link Range#contains(Comparable)} method. */