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 be6450a Fix a `NullPointerException` when visualizing an image with a "no data" value and no other category. In such case we have to synthetize a temporary category (not shown to user) for the remaining range of values. be6450a is described below commit be6450ad3f4885587bad4b162180802e3793fa05 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Fri Nov 5 19:20:13 2021 +0100 Fix a `NullPointerException` when visualizing an image with a "no data" value and no other category. In such case we have to synthetize a temporary category (not shown to user) for the remaining range of values. --- .../sis/internal/coverage/j2d/Colorizer.java | 117 +++++++++++++++------ .../sis/internal/coverage/j2d/ColorsForRange.java | 7 +- 2 files changed, 93 insertions(+), 31 deletions(-) diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java index 806ef60..b11dc09 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java +++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java @@ -37,6 +37,7 @@ import org.apache.sis.internal.feature.Resources; import org.apache.sis.internal.util.Numerics; import org.apache.sis.measure.NumberRange; import org.apache.sis.util.ArgumentChecks; +import org.apache.sis.util.ArraysExt; import org.apache.sis.util.resources.Errors; import org.apache.sis.util.resources.Vocabulary; import org.apache.sis.util.Debug; @@ -61,7 +62,7 @@ import org.apache.sis.util.Debug; * product as different depth or different time. * * @author Martin Desruisseaux (Geomatys) - * @version 1.1 + * @version 1.2 * * @see ColorModelType * @see ColorModelFactory#createColorModel(int, int, int, Collection) @@ -106,7 +107,7 @@ public final class Colorizer { Color.BLUE, Color.CYAN, Color.WHITE, Color.YELLOW, Color.RED} : null; /** - * The colors to use for each category. + * The colors to use for each category. Never {@code null}. * The function may return {@code null}, which means transparent. */ private final Function<Category,Color[]> colors; @@ -114,6 +115,7 @@ public final class Colorizer { /** * 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. */ private ColorsForRange[] entries; @@ -128,21 +130,30 @@ public final class Colorizer { * The sample dimension for values after conversion, or {@code null} if not yet computed. * May be the same than {@link #source} or {@code source.forConvertedValues(true)} if one * of those values is suitable, or a new sample dimension created by {@link #compact()}. + * + * <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> */ private SampleDimension target; /** + * Default range of values to use if no explicitly specified by a {@link Category}. + */ + private NumberRange<?> defaultRange; + + /** * Creates a new colorizer which will apply colors on the given range of values in source image. * The {@code Colorizer} is considered initialized after this constructor; * callers shall <strong>not</strong> invoke an {@code initialize(…)} method. * * @param colors the colors to use for each range of values in source image. - * A {@code null} value means transparent. + * A {@code null} entry value means transparent. */ public Colorizer(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors) { ArgumentChecks.ensureNonNull("colors", colors); entries = ColorsForRange.list(colors); - this.colors = null; + this.colors = GRAYSCALE; } /** @@ -190,12 +201,13 @@ public final class Colorizer { this.source = source; final List<Category> categories = source.getCategories(); if (!categories.isEmpty()) { - entries = new ColorsForRange[categories.size()]; + 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, category.getSampleRange(), colors.apply(category)); } // Leave `target` to null. It will be computed by `compact()` if needed. + this.entries = entries; return true; } } @@ -269,19 +281,20 @@ public final class Colorizer { checkInitializationStatus(false); ArgumentChecks.ensureFinite("minimum", minimum); ArgumentChecks.ensureFinite("maximum", maximum); + defaultRange = NumberRange.create(minimum, true, maximum, true); target = new SampleDimension.Builder() .setBackground(null, 0) .addQuantitative(Vocabulary.formatInternational(Vocabulary.Keys.Data), - NumberRange.create(1, true, MAX_VALUE, true), - NumberRange.create(minimum, true, maximum, true)).build(); + NumberRange.create(1, true, MAX_VALUE, true), defaultRange).build(); source = target.forConvertedValues(true); final List<Category> categories = source.getCategories(); - entries = new ColorsForRange[categories.size()]; + 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, category.forConvertedValues(false).getSampleRange(), colors.apply(category)); } + this.entries = entries; } /** @@ -303,8 +316,8 @@ public final class Colorizer { final ColorSpace cs = original.getColorSpace(); if (cs instanceof ScaledColorSpace) { final ScaledColorSpace scs = (ScaledColorSpace) cs; - final double minimum = scs.offset; - final double maximum = scs.maximum; + final double minimum = scs.offset; + final double maximum = scs.maximum; ColorsForRange widest = null; double widestSpan = 0; for (final ColorsForRange entry : entries) { @@ -315,8 +328,9 @@ public final class Colorizer { widest = entry; } } + defaultRange = NumberRange.create(minimum, true, maximum, false); if (widest != null && widestSpan != widest.sampleRange.getSpan()) { - widest.sampleRange = NumberRange.create(minimum, true, maximum, false); + widest.sampleRange = defaultRange; target = null; // For recomputing the transfer function later. } } @@ -344,9 +358,14 @@ public final class Colorizer { * If a source SampleDimension has been specified, verify if it provides a transfer function that we can * use directly. If this is the case, use the existing transfer function instead of inventing our own. */ + ColorsForRange[] entries = this.entries; reuse: if (source != null) { target = source.forConvertedValues(false); if (target.getSampleRange().filter(Colorizer::isAlreadyScaled).isPresent()) { + /* + * If we enter in this block, all sample values are already in the [0 … 255] range. + * If in addition there is no conversion to apply, then there is nothing to do. + */ if (target == source) { return; } @@ -378,7 +397,7 @@ reuse: if (source != null) { } } /* - * IF we reach this point, `source` sample dimensions were not specified or can not be used for + * If we reach this point, `source` sample dimensions were not specified or can not be used for * getting a transfer function to the [0 … 255] range of values. We will need to create our own. * First, sort the entries for having transparent colors first. */ @@ -387,23 +406,42 @@ reuse: if (source != null) { int lower = 0; // First available index in the [0 … 255] range. int deferred = 0; // Number of entries deferred to next loop. int count = entries.length; // Total number of valid entries. + NumberRange<?> themes = null; // The range of values in a thematic map. final Map<NumberRange<Integer>,ColorsForRange> mapper = new HashMap<>(); final SampleDimension.Builder builder = new SampleDimension.Builder(); /* * We will use the byte values range [0 … 255] with 0 reserved in priority for the most transparent pixels. * The first loop below processes NaN values, which are usually the ones associated to transparent pixels. - * The second loop processes everything else. + * The second loop (from 0 to `deferred`) will process everything else. */ for (int i=0; i<count; i++) { final ColorsForRange entry = entries[i]; - final double s = entry.sampleRange.getSpan(); - if (Double.isNaN(s)) { + NumberRange<?> sourceRange = entry.sampleRange; + final double s = sourceRange.getSpan(); + if (!entry.isData()) { if (lower >= MAX_VALUE) { throw new IllegalArgumentException(Resources.format(Resources.Keys.TooManyQualitatives)); } - final NumberRange<Integer> samples = NumberRange.create(lower, true, ++lower, false); - if (mapper.put(samples, entry) == null) { - builder.mapQualitative(entry.name(), samples, (float) s); + final NumberRange<Integer> targetRange = NumberRange.create(lower, true, ++lower, false); + if (mapper.put(targetRange, entry) == null) { + final CharSequence name = entry.name(); + final double value = sourceRange.getMinDouble(); + /* + * In the usual case where we have a mix of quantitative and qualitative categories, + * the qualitative ones (typically "no data" categories) are associated to NaN. + * Values are real only if all categories are qualitatives (e.g. a thematic map). + * In such case we will create pseudo-quantitative categories for the purpose of + * computing a transfer function, but those categories should not be returned to user. + */ + if (Double.isNaN(value)) { + builder.mapQualitative(name, targetRange, (float) value); + } else { + if (value == entry.sampleRange.getMaxDouble()) { + sourceRange = NumberRange.create(value - 0.5, true, value + 0.5, false); + } + builder.addQuantitative(name, targetRange, sourceRange); + themes = (themes != null) ? themes.unionAny(sourceRange) : sourceRange; + } } } else if (s > 0) { // Range of real values: defer processing to next loop. @@ -417,6 +455,27 @@ reuse: if (source != null) { } } /* + * Following block is executed only if the sample dimension defines only qualitative categories. + * This is the case of thematic (or classification) map. It may also happen because the coverage + * defined only a "no data" value with no information about the "real" values. In such case we + * generate an artificial quantitative category for mapping all remaining values to [0…255] range. + * The actual category creation happen in the loop after this block. + */ + if (deferred == 0 && themes != null) { + if (defaultRange == null) { + defaultRange = NumberRange.create(0, true, Short.MAX_VALUE + 1, false); + } + // Following loop will usually be executed only once. + for (final NumberRange<?> sourceRange : defaultRange.subtractAny(themes)) { + span += sourceRange.getSpan(); + final ColorsForRange[] tmp = new ColorsForRange[++count]; + System.arraycopy(entries, deferred, tmp, ++deferred, count - deferred); + tmp[deferred-1] = new ColorsForRange(null, sourceRange, new Color[] {Color.BLACK, Color.WHITE}); + entries = tmp; + } + } + this.entries = entries = ArraysExt.resize(entries, count); // Should be a no-op most of the times. + /* * Above loop mapped all NaN values. Now map the real values. Usually, there is exactly one entry taking * all remaining values in the [0 … 255] range, but code below is tolerant to arbitrary amount of ranges. */ @@ -425,19 +484,17 @@ reuse: if (source != null) { span = 0; for (int i=0; i<deferred; i++) { final ColorsForRange entry = entries[i]; - if (entry != null) { - span += entry.sampleRange.getSpan(); - final int upper = Math.toIntExact(Math.round(span * toIndexRange) + base); - if (upper <= lower) { - // May happen if too many qualitative categories have been added by previous loop. - throw new IllegalArgumentException(Resources.format(Resources.Keys.TooManyQualitatives)); - } - final NumberRange<Integer> samples = NumberRange.create(lower, true, upper, false); - if (mapper.put(samples, entry) == null) { - builder.addQuantitative(entry.name(), samples, entry.sampleRange); - } - lower = upper; + span += entry.sampleRange.getSpan(); + final int upper = Math.toIntExact(Math.round(span * toIndexRange) + base); + if (upper <= lower) { + // May happen if too many qualitative categories have been added by previous loop. + throw new IllegalArgumentException(Resources.format(Resources.Keys.TooManyQualitatives)); + } + final NumberRange<Integer> samples = NumberRange.create(lower, true, upper, false); + if (mapper.put(samples, entry) == null) { + builder.addQuantitative(entry.name(), samples, entry.sampleRange); } + lower = upper; } /* * At this point we created a `Category` instance for each given `ColorsForRange`. 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 32cf7e2..3236904 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 @@ -75,6 +75,11 @@ final class ColorsForRange implements Comparable<ColorsForRange> { /** * 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. + * @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) { final ColorsForRange[] entries = new ColorsForRange[colors.size()]; @@ -87,7 +92,7 @@ final class ColorsForRange implements Comparable<ColorsForRange> { /** * Returns {@code true} if this entry should be taken as data, or {@code false} if it should be ignored. - * Entry to ignore and entries associated to NaN values. + * Entry to ignore are entries associated to NaN values. */ final boolean isData() { return category == null || category.isQuantitative();