blob: d03658e87abffb36e0d046f7cc69665873784b6e [file] [log] [blame]
/*
* 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.Map;
import java.util.List;
import java.util.Arrays;
import java.util.function.DoubleUnaryOperator;
import java.awt.Shape;
import java.awt.image.ColorModel;
import java.awt.image.IndexColorModel;
import java.awt.image.SampleModel;
import java.awt.image.RenderedImage;
import org.apache.sis.coverage.Category;
import org.apache.sis.coverage.SampleDimension;
import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
import org.apache.sis.internal.coverage.j2d.ImageUtilities;
import org.apache.sis.internal.util.Numerics;
import org.apache.sis.util.collection.Containers;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.math.Statistics;
import org.apache.sis.measure.NumberRange;
/**
* An image with the same sample values than the wrapped image but a different color model.
* The only interesting member method is {@link #getColorModel()}, which returns the model
* specified at construction time. All other non-trivial methods are static helper methods
* for {@link ImageProcessor}, defined here for reducing {@link ImageProcessor} size.
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.2
* @since 1.1
* @module
*/
final class RecoloredImage extends ImageAdapter {
/**
* The color model to associate with this recolored image.
*
* @see #getColorModel()
*/
private final ColorModel colors;
/**
* The minimum and maximum values used for computing the color model.
* 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.
* Future version may allow more sophisticated ways to redistribute the colors. The possibilities are
* determined by {@link #stretchColorRamp(ImageProcessor, RenderedImage, Map)} implementation.</p>
*/
final double minimum, maximum;
/**
* Creates a new recolored image with the given colors.
*
* @param source the image to wrap.
* @param colors the new color model.
* @param minimum the minimal sample value used for computing the color model.
* @param maximum the maximal sample value used for computing the color model.
*/
private RecoloredImage(final RenderedImage source, final ColorModel colors, final double minimum, final double maximum) {
super(source);
this.colors = colors;
this.minimum = minimum;
this.maximum = maximum;
}
/**
* Returns a recolored image with the same colors than the given image.
* This method may return an existing ancestor if one is found with the desired color model.
*
* @param source the image to wrap.
* @param colored the image from which to preserve the color model.
*/
static RenderedImage applySameColors(RenderedImage source, RenderedImage colored) {
final ColorModel colors = colored.getColorModel();
if (colors == null) {
return source;
}
/*
* Find the image which contains the minimum and maximum values that we want to keep.
* We can skip `ImageAdapter` because those images may modify properties but not sample values.
*/
RecoloredImage expected = null;
while (colored instanceof ImageAdapter) {
if (colored instanceof RecoloredImage) {
expected = (RecoloredImage) colored;
break;
}
colored = ((ImageAdapter) colored).source;
}
/*
* Verify if the given image, or one of its sources, has the expected color model.
* We explore only the sources that are themselves `RecoloredImage` instances,
* because other kind of images are result of operations that we want to keep.
*/
for (;;) {
if (colors.equals(source.getColorModel())) {
if (expected != null && source instanceof RecoloredImage) {
final RecoloredImage actual = (RecoloredImage) source;
if (!(Numerics.equals(expected.minimum, actual.minimum) &&
Numerics.equals(expected.maximum, actual.maximum)))
{
continue;
}
}
return source;
}
if (source instanceof RecoloredImage) {
source = ((RecoloredImage) source).source;
} else {
break;
}
}
/*
* 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);
}
/**
* Returns an image with the same sample values than the given image, but with its color ramp stretched
* between specified or inferred bounds. The mapping applied by this method is conceptually a linear
* transform applied on sample values before they are mapped to their colors.
*
* <p>Current implementation can stretch gray scale and {@linkplain IndexColorModel index color models}).
* If this method can not stretch the color ramp, for example because the given image is an RGB image,
* then the image is returned unchanged.</p>
*
* @param processor the processor to use for computing statistics if needed.
* @param source the image to recolor (can be {@code null}).
* @param modifiers modifiers for narrowing the range of values, or {@code null} if none.
* @return the image with color ramp stretched between the automatic bounds,
* or {@code image} unchanged if the operation can not be applied on the given image.
*
* @see ImageProcessor#stretchColorRamp(RenderedImage, Map)
*/
static RenderedImage stretchColorRamp(final ImageProcessor processor, RenderedImage source,
final Map<String,?> modifiers)
{
/*
* Images having more than one band (without any band marked as the single band to show) are probably
* RGB images. It would be possible to stretch the Red, Green and Blue bands separately, but current
* implementation don't do that because we do not have yet a clear use case.
*/
final int visibleBand = ImageUtilities.getVisibleBand(source);
if (visibleBand < 0) {
return source;
}
/*
* Main use case: color model is (probably) an IndexColorModel or ScaledColorModel instance,
* or something we can handle in the same way.
*/
RenderedImage statsSource = source;
Statistics[] statsAllBands = null;
Statistics statistics = null;
Shape areaOfInterest = null;
Number[] nodataValues = null;
SampleDimension range = null;
double minimum = Double.NaN;
double maximum = Double.NaN;
double deviations = Double.POSITIVE_INFINITY;
/*
* Extract and validate parameter values.
* No calculation started at this stage.
*/
if (modifiers != null) {
final Number minValue = Containers.property(modifiers, "minimum", Number.class);
final Number maxValue = Containers.property(modifiers, "maximum", Number.class);
if (minValue != null) minimum = minValue.doubleValue();
if (maxValue != null) maximum = maxValue.doubleValue();
if (minimum >= maximum) {
throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalRange_2, minValue, maxValue));
}
{ // For keeping `value` in local scope.
final Number value = Containers.property(modifiers, "multStdDev", Number.class);
if (value != null) {
deviations = value.doubleValue();
ArgumentChecks.ensureStrictlyPositive("multStdDev", deviations);
}
}
areaOfInterest = Containers.property(modifiers, "areaOfInterest", Shape.class);
Object value = modifiers.get("nodataValues");
if (value != null) {
if (value instanceof Number) {
nodataValues = new Number[] {(Number) value};
} else if (value instanceof Number[]) {
nodataValues = (Number[]) value;
} else {
throw illegalPropertyType(modifiers, "nodataValues", value);
}
}
value = modifiers.get("statistics");
if (value != null) {
if (value instanceof RenderedImage) {
statsSource = (RenderedImage) value;
} else if (value instanceof Statistics) {
statistics = (Statistics) value;
} else if (value instanceof Statistics[]) {
statsAllBands = (Statistics[]) value;
} else {
throw illegalPropertyType(modifiers, "statistics", value);
}
}
value = modifiers.get("sampleDimensions");
if (value != null) {
if (value instanceof List<?>) {
final List<?> ranges = (List<?>) value;
if (visibleBand < ranges.size()) {
value = ranges.get(visibleBand);
}
}
if (value != null) {
if (value instanceof SampleDimension) {
range = (SampleDimension) value;
} else {
throw illegalPropertyType(modifiers, "sampleDimensions", value);
}
}
}
}
/*
* If minimum and maximum values were not explicitly specified, compute them from statistics.
* If the range is not valid, then the image will be silently returned as-is.
*/
if (Double.isNaN(minimum) || Double.isNaN(maximum)) {
if (statistics == null) {
if (statsAllBands == null) {
final DoubleUnaryOperator[] sampleFilters = new DoubleUnaryOperator[visibleBand + 1];
sampleFilters[visibleBand] = processor.filterNodataValues(nodataValues);
statsAllBands = processor.valueOfStatistics(statsSource, areaOfInterest, sampleFilters);
}
if (statsAllBands != null && visibleBand < statsAllBands.length) {
statistics = statsAllBands[visibleBand];
}
}
if (statistics != null) {
deviations *= statistics.standardDeviation(true);
final double mean = statistics.mean();
if (Double.isNaN(minimum)) minimum = Math.max(statistics.minimum(), mean - deviations);
if (Double.isNaN(maximum)) maximum = Math.min(statistics.maximum(), mean + deviations);
}
}
if (!(minimum < maximum)) { // Use ! for catching NaN.
return source;
}
/*
* Finished to collect information. Derive a new color model from the existing one.
*/
final ColorModel cm;
if (source.getColorModel() instanceof IndexColorModel) {
/*
* Get the range of indices of RGB values that can be used for interpolations.
* We want to exclude qualitative categories (no data, clouds, forests, etc.).
* In the vast majority of cases, we have at most one quantitative category.
* 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 int size = icm.getMapSize();
int validMin = 0;
int validMax = size - 1; // Inclusive.
if (range != null) {
double span = 0;
for (final Category category : range.getCategories()) {
if (category.isQuantitative()) {
final NumberRange<?> r = category.getSampleRange();
final double min = Math.max(r.getMinDouble(true), 0);
final double max = Math.min(r.getMaxDouble(true), size - 1);
final double s = Math.min(max, maximum) - Math.max(min, minimum); // Intersection.
if (s > span) {
validMin = (int) min;
validMax = (int) max;
span = s;
}
}
}
}
/*
* Create a copy of RGB codes and replace values in the range of the quantitative category.
* Values for other categories (qualitative) are left unmodified.
*/
final int start = Math.max((int) minimum, validMin);
final int end = Math.min((int) maximum, validMax); // Inclusive.
final int[] ARGB = new int[size];
icm.getRGBs(ARGB); // Initialize to a copy of current colors.
Arrays.fill(ARGB, validMin, start, icm.getRGB(validMin)); // Part of quantitative category outside the new range.
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));
}
final SampleModel sm = source.getSampleModel();
cm = ColorModelFactory.createIndexColorModel(sm.getNumBands(), visibleBand, ARGB, icm.hasAlpha(), icm.getTransparentPixel());
} else {
/*
* Wraps the given image with its colors ramp scaled between the given bounds. If the given image is
* already using a color ramp for the given range of values, then that image is returned unchanged.
*/
final SampleModel sm = source.getSampleModel();
cm = ColorModelFactory.createGrayScale(sm.getDataType(), sm.getNumBands(), visibleBand, minimum, maximum);
}
/*
* Verify if an existing ancestor already have the specified color model.
* If not, built the new `RecoloredImage` here.
*/
for (;;) {
if (cm.equals(source.getColorModel())) {
if (source instanceof RecoloredImage) {
final RecoloredImage colored = (RecoloredImage) source;
if (colored.minimum != minimum || colored.maximum != maximum) {
continue;
}
}
return source;
} else if (source instanceof RecoloredImage) {
source = ((RecoloredImage) source).source;
} else {
break;
}
}
return ImageProcessor.unique(new RecoloredImage(source, cm, minimum, maximum));
}
/**
* Returns the exception to be thrown when a property is of illegal type.
*/
private static IllegalArgumentException illegalPropertyType(
final Map<String,?> properties, final String key, final Object value)
{
return new IllegalArgumentException(Errors.getResources(properties)
.getString(Errors.Keys.IllegalPropertyValueClass_2, key, value.getClass()));
}
/**
* Returns the color model of this image.
*/
@Override
public ColorModel getColorModel() {
return colors;
}
/**
* Compares the given object with this image for equality.
*/
@Override
public boolean equals(final Object object) {
if (super.equals(object)) {
final RecoloredImage other = (RecoloredImage) object;
return Numerics.equals(minimum, other.minimum) &&
Numerics.equals(maximum, other.maximum) &&
colors.equals(other.colors);
}
return false;
}
/**
* Returns a hash code value for this image.
*/
@Override
public int hashCode() {
return super.hashCode() + 37 * colors.hashCode();
}
/**
* Appends a content to show in the {@link #toString()} representation,
* after the class name and before the string representation of the wrapped image.
*/
@Override
final Class<RecoloredImage> appendStringContent(final StringBuilder buffer) {
buffer.append(colors.getColorSpace());
return RecoloredImage.class;
}
}