| /* |
| * 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.internal.map.coverage; |
| |
| import java.util.Map; |
| import java.util.List; |
| import java.util.HashMap; |
| import java.util.Objects; |
| import java.io.IOException; |
| import java.io.UncheckedIOException; |
| import java.awt.Graphics2D; |
| import java.awt.Rectangle; |
| import java.awt.image.BufferedImage; |
| import java.awt.image.RenderedImage; |
| import java.awt.geom.Rectangle2D; |
| import java.awt.geom.AffineTransform; |
| import java.awt.geom.NoninvertibleTransformException; |
| import java.util.logging.Logger; |
| import org.opengis.util.FactoryException; |
| import org.opengis.geometry.DirectPosition; |
| import org.opengis.metadata.extent.GeographicBoundingBox; |
| import org.opengis.referencing.datum.PixelInCell; |
| import org.opengis.referencing.operation.Matrix; |
| import org.opengis.referencing.operation.CoordinateOperation; |
| import org.opengis.referencing.operation.MathTransform; |
| import org.opengis.referencing.operation.TransformException; |
| import org.opengis.referencing.crs.CoordinateReferenceSystem; |
| import org.apache.sis.storage.DataStoreException; |
| import org.apache.sis.coverage.grid.GridCoverage; |
| import org.apache.sis.coverage.grid.GridGeometry; |
| import org.apache.sis.coverage.grid.GridExtent; |
| import org.apache.sis.coverage.grid.ImageRenderer; |
| import org.apache.sis.coverage.grid.PixelTranslation; |
| import org.apache.sis.coverage.SampleDimension; |
| import org.apache.sis.geometry.AbstractEnvelope; |
| import org.apache.sis.geometry.Envelope2D; |
| import org.apache.sis.geometry.Shapes2D; |
| import org.apache.sis.image.PlanarImage; |
| import org.apache.sis.image.ErrorHandler; |
| import org.apache.sis.image.ImageProcessor; |
| import org.apache.sis.internal.coverage.SampleDimensions; |
| import org.apache.sis.internal.coverage.j2d.ColorModelType; |
| import org.apache.sis.internal.coverage.j2d.ImageUtilities; |
| import org.apache.sis.internal.referencing.WraparoundApplicator; |
| import org.apache.sis.internal.system.Modules; |
| import org.apache.sis.io.TableAppender; |
| import org.apache.sis.math.Statistics; |
| import org.apache.sis.measure.Quantities; |
| import org.apache.sis.measure.Units; |
| import org.apache.sis.metadata.iso.extent.Extents; |
| import org.apache.sis.referencing.operation.transform.LinearTransform; |
| import org.apache.sis.referencing.operation.transform.MathTransforms; |
| import org.apache.sis.referencing.operation.matrix.AffineTransforms2D; |
| import org.apache.sis.referencing.CRS; |
| import org.apache.sis.util.Debug; |
| import org.apache.sis.util.ArraysExt; |
| import org.apache.sis.util.Utilities; |
| import org.apache.sis.util.logging.Logging; |
| import org.apache.sis.portrayal.PlanarCanvas; // For javadoc. |
| |
| |
| /** |
| * The {@code RenderedImage} to draw in a {@link PlanarCanvas} together with transforms from pixel coordinates |
| * to display coordinates. This is a helper class for implementations of stateful renderer. |
| * All grid geometries and transforms managed by this class are two-dimensional. |
| * If the source data have more dimensions, a two-dimensional slice will be taken. |
| * |
| * <h2>Note on Java2D optimizations</h2> |
| * {@link Graphics2D#drawRenderedImage(RenderedImage, AffineTransform)} implementation |
| * has the following optimizations: |
| * |
| * <ul class="verbose"> |
| * <li>If the image is an instance of {@link BufferedImage}, |
| * then the {@link AffineTransform} can be anything. Java2D applies interpolations efficiently.</li> |
| * <li>Otherwise if the {@link AffineTransform} scale factors are 1 and the translations are integers, |
| * then Java2D invokes {@link RenderedImage#getTile(int, int)}. It makes possible for us to create |
| * a very large image covering the whole data but with tiles computed only when first requested.</li> |
| * <li>Otherwise Java2D invokes {@link RenderedImage#getData(Rectangle)}, which is more costly. |
| * We try to avoid that situation.</li> |
| * </ul> |
| * |
| * Consequently, our strategy is to prepare a resampled image for the whole data when the zoom level changed |
| * and rely on tiling for reducing actual computations to required tiles. Since pan gestures are expressed |
| * in pixel coordinates, the translation terms in {@code resampledToDisplay} transform should stay integers. |
| * |
| * <p>Current version of this class does not perform a special case for {@link BufferedImage}. |
| * It may not be desirable because interpolations would not be applied in the same way, except |
| * when SIS {@link ImageProcessor} would have interpolated RGB color values anyway like Java2D. |
| * We wait to see if this class works well in the general case before doing special cases.</p> |
| * |
| * @author Martin Desruisseaux (Geomatys) |
| * @version 1.4 |
| * @since 1.1 |
| */ |
| public class RenderingData implements Cloneable { |
| /** |
| * The logger for portrayal. |
| */ |
| private static final Logger LOGGER = Logger.getLogger(Modules.PORTRAYAL); |
| |
| /** |
| * The {@value} value, for identifying code that assume two-dimensional objects. |
| * |
| * @see #xyDimensions |
| */ |
| private static final int BIDIMENSIONAL = 2; |
| |
| /** |
| * Whether to allow the creation of {@link java.awt.image.IndexColorModel}. This flag may be temporarily set |
| * to {@code false} for testing or debugging. If {@code false}, images may be only grayscale and may be much |
| * slower to render, but should still be visible. |
| */ |
| @Debug |
| private static final boolean CREATE_INDEX_COLOR_MODEL = true; |
| |
| /** |
| * Loader for reading and caching coverages at various resolutions. |
| * Required if no image has been explicitly assigned to {@link #data}. |
| * The same instance may be shared by many {@link RenderingData} objects. |
| */ |
| public MultiResolutionCoverageLoader coverageLoader; |
| |
| /** |
| * The pyramid level of {@linkplain #data} loaded by the {@linkplain #coverageLoader}. |
| * Value 0 is finest resolution. |
| */ |
| private int currentPyramidLevel; |
| |
| /** |
| * The slice extent which has been used for rendering the {@linkplain #data}. |
| * May be {@code null} if the grid coverage has only two dimensions with a size greater than 1 cell. |
| */ |
| private GridExtent currentSlice; |
| |
| /** |
| * The dimensions to select in the grid coverage for producing an image. |
| * This is an array of length {@value #BIDIMENSIONAL} almost always equal to {0,1}. |
| * The values are inferred from {@link #currentSlice}. |
| */ |
| private int[] xyDimensions; |
| |
| /** |
| * The data fetched from {@link GridCoverage#render(GridExtent)} for {@link #currentSlice}. |
| * This rendered image may be tiled and fetching those tiles may require computations to be performed |
| * in background threads. Pixels in this {@code data} image are mapped to pixels in the display |
| * {@link PlanarCanvas} by the following chain of operations: |
| * |
| * <ol> |
| * <li><code>{@linkplain #dataGeometry}.getGridGeometry(CELL_CENTER)</code></li> |
| * <li><code>{@linkplain #changeOfCRS}.getMathTransform()</code></li> |
| * <li>{@link PlanarCanvas#getObjectiveToDisplay()}</li> |
| * </ol> |
| * |
| * This field is initially {@code null}. |
| * |
| * @see #dataGeometry |
| * @see #dataRanges |
| * @see #ensureImageLoaded(GridCoverage, GridExtent, boolean) |
| * @see #getSourceImage() |
| */ |
| private RenderedImage data; |
| |
| /** |
| * Conversion from {@link #data} pixel coordinates to the coverage CRS, together with geospatial area. |
| * It contains the {@link GridGeometry#getGridToCRS(PixelInCell)} value of {@link GridCoverage} reduced |
| * to two dimensions and with a translation added for taking in account the requested {@code sliceExtent}. |
| * The coverage CRS is initially the same as the {@linkplain PlanarCanvas#getObjectiveCRS() objective CRS}, |
| * but may become different later if user selects a different objective CRS. |
| * |
| * @see #data |
| * @see #dataRanges |
| * @see #setImageSpace(GridGeometry, List, int[]) |
| */ |
| private GridGeometry dataGeometry; |
| |
| /** |
| * Ranges of sample values in each band of {@link #data}. This is used for determining on which sample values |
| * to apply colors when user asked to apply a color ramp. May be {@code null}. |
| * |
| * @see #setImageSpace(GridGeometry, List, int[]) |
| * @see #statistics() |
| */ |
| private List<SampleDimension> dataRanges; |
| |
| /** |
| * Conversion or transformation from {@linkplain #data} CRS to {@linkplain PlanarCanvas#getObjectiveCRS() |
| * objective CRS}, or {@code null} if not yet computed. This is an identity operation if the user did not |
| * selected a different CRS after the coverage has been shown. |
| */ |
| private CoordinateOperation changeOfCRS; |
| |
| /** |
| * Conversion from {@link #data} pixel coordinates to {@linkplain PlanarCanvas#getObjectiveCRS() objective CRS}. |
| * This is value of {@link GridGeometry#getGridToCRS(PixelInCell)} invoked on {@link #dataGeometry}, concatenated |
| * with {@link #changeOfCRS} and potentially completed by a wraparound operation. |
| * May be {@code null} if not yet computed. |
| */ |
| private MathTransform cornerToObjective; |
| |
| /** |
| * Conversion from {@linkplain PlanarCanvas#getObjectiveCRS() objective CRS} to {@link #data} pixel coordinates. |
| * This is the inverse of {@link #changeOfCRS} (potentially with a wraparound operation) concatenated with inverse |
| * of {@link GridGeometry#getGridToCRS(PixelInCell)} on {@link #dataGeometry}. |
| * May be {@code null} if not yet computed. |
| */ |
| private MathTransform objectiveToCenter; |
| |
| /** |
| * The inverse of the {@linkplain PlanarCanvas#objectiveToDisplay objective to display} transform which was |
| * active at the time resampled images have been computed. The concatenation of this transform with the actual |
| * "objective to display" transform at the time the rendered image is drawn should be a translation. |
| * May be {@code null} if not yet computed. |
| * |
| * @see #getTransform(LinearTransform) |
| */ |
| private AffineTransform displayToObjective; |
| |
| /** |
| * Statistics on pixel values of current {@link #data}, or {@code null} if not yet computed. |
| * This is the cached value of {@link #statistics()}. |
| * |
| * @see #statistics() |
| */ |
| private Statistics[] statistics; |
| |
| /** |
| * The processor that we use for resampling image and recoloring the image. |
| */ |
| public final ImageProcessor processor; |
| |
| /** |
| * Creates a new instance initialized to no image. |
| * |
| * @param errorHandler where to report errors during tile computations. |
| */ |
| public RenderingData(final ErrorHandler errorHandler) { |
| processor = new ImageProcessor(); |
| processor.setErrorHandler(errorHandler); |
| processor.setImageResizingPolicy(ImageProcessor.Resizing.EXPAND); |
| } |
| |
| /** |
| * Clears this renderer. This method should be invoked when the source of data (resource or coverage) changed. |
| * The {@link #displayToObjective} transform will be recomputed from scratch when first needed. |
| */ |
| public final void clear() { |
| clearCRS(); |
| coverageLoader = null; |
| displayToObjective = null; |
| statistics = null; |
| data = null; |
| dataRanges = null; |
| dataGeometry = null; |
| xyDimensions = null; |
| currentSlice = null; |
| } |
| |
| /** |
| * Clears the cache of transforms that depend on the CRS. |
| */ |
| private void clearCRS() { |
| changeOfCRS = null; |
| cornerToObjective = null; |
| objectiveToCenter = null; |
| } |
| |
| /** |
| * Verifies if this {@code RenderingData} contains an image for the given objective CRS. |
| * If this is not the case, the cached resampled images will need to be discarded. |
| * |
| * @param objectiveCRS the coordinate reference system to use for rendering. |
| * @return whether the data are valid for the given objective CRS. |
| */ |
| public final boolean validateCRS(final CoordinateReferenceSystem objectiveCRS) { |
| if (changeOfCRS != null && !Utilities.equalsIgnoreMetadata(objectiveCRS, changeOfCRS.getTargetCRS())) { |
| clearCRS(); |
| return false; |
| } |
| return true; |
| } |
| |
| /** |
| * Sets the input space (domain) and output space (ranges) of the image to be rendered. |
| * Those values can be initially provided by {@link org.apache.sis.storage.GridCoverageResource} |
| * and replaced later by the actual {@link GridCoverage} values after coverage loading is completed. |
| * It is caller's responsibility to reduce <var>n</var>-dimensional domain to two dimensions. |
| * |
| * @param domain the two-dimensional grid geometry, or {@code null} if there is no data. |
| * @param ranges descriptions of bands, or {@code null} if there is no data. |
| * @param xyDims the dimensions to select in the grid coverage for producing an image. |
| * This is an array of length {@value #BIDIMENSIONAL} almost always equal to {0,1}. |
| */ |
| @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter") |
| public final void setImageSpace(final GridGeometry domain, final List<SampleDimension> ranges, final int[] xyDims) { |
| processor.setFillValues(SampleDimensions.backgrounds(ranges)); |
| dataRanges = ranges; // Not cloned because already an unmodifiable list. |
| dataGeometry = domain; |
| xyDimensions = xyDims; |
| /* |
| * If the grid geometry does not define a "grid to CRS" transform, set it to an identity transform. |
| * We do that because this class needs a complete `GridGeometry` as much as possible. |
| */ |
| if (domain != null && !domain.isDefined(GridGeometry.GRID_TO_CRS) |
| && domain.isDefined(GridGeometry.EXTENT)) |
| { |
| CoordinateReferenceSystem crs = null; |
| if (domain.isDefined(GridGeometry.CRS)) { |
| crs = domain.getCoordinateReferenceSystem(); |
| } |
| final GridExtent extent = domain.getExtent(); |
| dataGeometry = new GridGeometry(extent, PixelInCell.CELL_CENTER, |
| MathTransforms.identity(extent.getDimension()), crs); |
| } |
| } |
| |
| /** |
| * Loads a new grid coverage if {@linkplain #data} is null or if the pyramid level changed. |
| * It is caller's responsibility to ensure that {@link #coverageLoader} has a non-null value |
| * and is using the right resource before to invoke this method. |
| * |
| * <p>Caller should invoke {@link #ensureImageLoaded(GridCoverage, GridExtent, boolean)} |
| * after this method (this is not done automatically).</p> |
| * |
| * @param objectiveToDisplay transform used for rendering the coverage on screen. |
| * @param objectivePOI point where to compute resolution, in coordinates of objective CRS. |
| * @return the loaded grid coverage, or {@code null} if no loading has been done |
| * (which means that the coverage is unchanged, not that it does not exist). |
| * @throws TransformException if an error occurred while computing resolution from given transforms. |
| * @throws DataStoreException if an error occurred while loading the coverage. |
| * |
| * @see #setImageSpace(GridGeometry, List, int[]) |
| */ |
| public final GridCoverage ensureCoverageLoaded(final LinearTransform objectiveToDisplay, final DirectPosition objectivePOI) |
| throws TransformException, DataStoreException |
| { |
| final MathTransform dataToObjective = (changeOfCRS != null) ? changeOfCRS.getMathTransform() : null; |
| final MultiResolutionCoverageLoader loader = coverageLoader; |
| final int level = loader.findPyramidLevel(dataToObjective, objectiveToDisplay, objectivePOI); |
| if (data != null && level == currentPyramidLevel) { |
| return null; |
| } |
| data = null; |
| currentPyramidLevel = level; |
| return loader.getOrLoad(level); |
| } |
| |
| /** |
| * Fetches the rendered image if {@linkplain #data} is null or is for a different slice. |
| * This method needs to be invoked at least once after {@link #setImageSpace(GridGeometry, List, int[])}. |
| * The {@code coverage} given in argument should be the value returned by a previous call to |
| * {@link #ensureCoverageLoaded(LinearTransform, DirectPosition)}, except that it shall not be null. |
| * |
| * @param coverage the coverage from which to read data. Shall not be null. |
| * @param sliceExtent a subspace of the grid coverage extent where all dimensions except two have a size of 1 cell. |
| * May be {@code null} if this grid coverage has only two dimensions with a size greater than 1 cell. |
| * @param force whether to force data loading. Should be {@code true} if {@code coverage} changed since last call. |
| * @return whether the {@linkpalin #data} changed. |
| * @throws FactoryException if the CRS changed but the transform from old to new CRS cannot be determined. |
| * @throws TransformException if an error occurred while transforming coordinates from old to new CRS. |
| */ |
| public final boolean ensureImageLoaded(GridCoverage coverage, final GridExtent sliceExtent, final boolean force) |
| throws FactoryException, TransformException |
| { |
| if (!force && data != null && Objects.equals(currentSlice, sliceExtent)) { |
| return false; |
| } |
| coverage = coverage.forConvertedValues(true); |
| final GridGeometry old = dataGeometry; |
| final List<SampleDimension> ranges = coverage.getSampleDimensions(); |
| final RenderedImage image = coverage.render(sliceExtent); |
| final Object value = image.getProperty(PlanarImage.GRID_GEOMETRY_KEY); |
| final GridGeometry domain; |
| final int[] xyDims; |
| if (value instanceof GridGeometry) { |
| domain = (GridGeometry) value; |
| xyDims = (sliceExtent == null) ? ArraysExt.range(0, BIDIMENSIONAL) |
| : sliceExtent.getSubspaceDimensions(BIDIMENSIONAL); |
| } else { |
| ImageRenderer r = new ImageRenderer(coverage, sliceExtent); |
| domain = r.getImageGeometry(BIDIMENSIONAL); |
| xyDims = r.getXYDimensions(); |
| } |
| setImageSpace(domain, ranges, xyDims); // Implies `dataGeometry = domain`. |
| currentSlice = sliceExtent; |
| data = image; |
| /* |
| * Update the transforms in a way that preserve the current zoom level, translation, etc. |
| * We compute the change in the "data grid to objective CRS" transforms caused by the change |
| * in data grid geometry, then we concatenate that change to the existing transforms. |
| * That way, the objective CRS is kept unchanged. |
| */ |
| if (old != null && cornerToObjective != null && objectiveToCenter != null) { |
| MathTransform toNew = null, toOld = null; |
| if (old.isDefined(GridGeometry.CRS) && domain.isDefined(GridGeometry.CRS)) { |
| final CoordinateReferenceSystem oldCRS = old.getCoordinateReferenceSystem(); |
| final CoordinateReferenceSystem newCRS = dataGeometry.getCoordinateReferenceSystem(); |
| if (newCRS != oldCRS) { // Quick check for the vast majority of cases. |
| /* |
| * Transform computed below should always be the identity transform, |
| * but we check anyway as a safety. A non-identity transform would be |
| * a pyramid where the CRS changes according the pyramid level. |
| */ |
| final GeographicBoundingBox areaOfInterest = Extents.union( |
| dataGeometry.getGeographicExtent().orElse(null), |
| old.getGeographicExtent().orElse(null)); |
| toNew = CRS.findOperation(oldCRS, newCRS, areaOfInterest).getMathTransform(); |
| toOld = toNew.inverse(); |
| } |
| } |
| /* |
| * `inverse` is the transform from new grid coordinates to old grid coordinates. |
| * `forward` is the converse, with the addition of half-pixel translation terms. |
| */ |
| final MathTransform inverse = concatenate(PixelInCell.CELL_CORNER, dataGeometry, old, toOld); |
| final MathTransform forward = concatenate(PixelInCell.CELL_CENTER, old, dataGeometry, toNew); |
| cornerToObjective = MathTransforms.concatenate(inverse, cornerToObjective); |
| objectiveToCenter = MathTransforms.concatenate(objectiveToCenter, forward); |
| } |
| return true; |
| /* |
| * Note: the `forward` transform above is of particular interest and may be returned in a future version. |
| * It is the transform from new pixel coordinates to old pixel coordinates of the data before resampling |
| * (i.e. ignoring changes caused by user's zoom or pan gestures on the map). Typical values are: |
| * |
| * • An identity transform, meaning that the data changed but the new data uses the same pixel coordinates |
| * than the previous data. For example the user may have selected a new slice in a three-dimensional cube. |
| * • An affine transform represented by a diagonal matrix, i.e. with only scale factors and no translation. |
| * It happens when there is a change of resolution between the previous data and the new one, for example |
| * because a zoom change caused a change of pyramid level in `MultiResolutionCoverageLoader`. |
| * In such case the scale factors are typically 0.5 (after zoom-in) or 2 (after zoom out). |
| * |
| * That transform has already been applied to `RenderingData` internal state, |
| * but maybe some caller will need to apply that change to its own data. |
| * We wait to see if such need happens. |
| */ |
| } |
| |
| /** |
| * Computes the transform that represent a change of "data grid to objective" transform |
| * |
| * @param anchor the cell part to map (center or corner). |
| * @param toCRS the grid geometry for which to use the "grid to CRS" transform. |
| * @param toGrid the grid geometry for which to use the "CRS to grid" transform. |
| * @param changeOfCRS transform from CRS of {@code toCRS} to CRS of {@code toGrid}. |
| */ |
| private static MathTransform concatenate(final PixelInCell anchor, final GridGeometry toCRS, |
| final GridGeometry toGrid, final MathTransform changeOfCRS) throws TransformException |
| { |
| final MathTransform forward = toCRS .getGridToCRS(anchor); |
| final MathTransform inverse = toGrid.getGridToCRS(anchor).inverse(); |
| if (changeOfCRS != null) { |
| return MathTransforms.concatenate(forward, changeOfCRS, inverse); |
| } else { |
| return MathTransforms.concatenate(forward, inverse); |
| } |
| } |
| |
| /** |
| * Returns the image which will be used as the source for rendering operations. |
| * |
| * @return the image loaded be {@link #ensureImageLoaded(GridCoverage, GridExtent, boolean)}. |
| */ |
| public final RenderedImage getSourceImage() { |
| return data; |
| } |
| |
| /** |
| * Returns the position at the center of source data, or {@code null} if none. |
| * The coordinates are expressed in the CRS of the source coverage. |
| */ |
| private DirectPosition getSourceMedian() { |
| if (dataGeometry.isDefined(GridGeometry.ENVELOPE)) { |
| return AbstractEnvelope.castOrCopy(dataGeometry.getEnvelope()).getMedian(); |
| } |
| return null; |
| } |
| |
| /** |
| * Returns statistics on the source image (computed when first requested, then cached). |
| * There is one {@link Statistics} instance per band. This is an information for dynamic |
| * stretching of image color ramp. Such recoloring operation should use statistics on the |
| * source image instead of statistics on the shown image in order to have stable colors |
| * during pans or zooms. |
| * |
| * <p>The returned map is suitable for use with {@link ImageProcessor#stretchColorRamp(RenderedImage, Map)}. |
| * The map content is:</p> |
| * <ul> |
| * <li>{@code "statistics"}: the statistics as a {@code Statistics[]} array.</li> |
| * <li>{@code "sampleDimensions"}: band descriptions as a {@code List<SampleDimension>}.</li> |
| * </ul> |
| * |
| * This operation may be costly since it causes the loading of full image. |
| * If {@link #coverageLoader} is non-null, statistics will be computed on the |
| * image with coarsest resolution. |
| * |
| * @return statistics on sample values for each band, in a modifiable map. |
| * @throws DataStoreException if an error occurred while reading the image at coarsest resolution. |
| */ |
| protected final Map<String,Object> statistics() throws DataStoreException { |
| if (statistics == null) { |
| RenderedImage image = data; |
| final MultiResolutionCoverageLoader loader = coverageLoader; |
| if (loader != null) { |
| final int level = loader.getLastLevel(); |
| if (level != currentPyramidLevel) { |
| /* |
| * If coarser data are available, we will compute statistics on those data instead of on the |
| * current pyramid level. We need to adjust the slice extent to the coordinates of coarser data. |
| */ |
| final GridCoverage coarse = loader.getOrLoad(level).forConvertedValues(true); |
| GridExtent sliceExtent = currentSlice; |
| if (sliceExtent != null) { |
| if (sliceExtent.getDimension() <= BIDIMENSIONAL) { |
| sliceExtent = null; |
| } else { |
| final GridExtent ce = coarse.getGridGeometry().getExtent(); |
| for (final int i : xyDimensions) { |
| sliceExtent = sliceExtent.withRange(i, ce.getLow(i), ce.getHigh(i)); |
| } |
| } |
| } |
| image = coarse.render(sliceExtent); |
| } |
| } |
| statistics = processor.valueOfStatistics(image, null, SampleDimensions.toSampleFilters(processor, dataRanges)); |
| } |
| final Map<String,Object> modifiers = new HashMap<>(8); |
| modifiers.put("statistics", statistics); |
| modifiers.put("sampleDimensions", dataRanges); |
| return modifiers; |
| } |
| |
| /** |
| * Sets the coordinate reference system of the display. This method does nothing if the CRS was already set. |
| * <em>It does not verify if CRS is the same</em>, it is caller responsibility to clear {@link #changeOfCRS} |
| * before to invoke this method for forcing a change of CRS. |
| * |
| * <p>This method updates the following fields only:</p> |
| * <ul> |
| * <li>{@link #changeOfCRS}</li> |
| * <li>{@link #processor} positional accuracy hint</li> |
| * </ul> |
| * |
| * @param objectiveCRS value of {@link PlanarCanvas#getObjectiveCRS()}. |
| * @throws TransformException if an error occurred while transforming coordinates from grid to new CRS. |
| */ |
| public final void setObjectiveCRS(final CoordinateReferenceSystem objectiveCRS) throws TransformException { |
| if (changeOfCRS == null && objectiveCRS != null && dataGeometry.isDefined(GridGeometry.CRS)) try { |
| changeOfCRS = CRS.findOperation(dataGeometry.getCoordinateReferenceSystem(), objectiveCRS, |
| dataGeometry.getGeographicExtent().orElse(null)); |
| final double accuracy = CRS.getLinearAccuracy(changeOfCRS); |
| processor.setPositionalAccuracyHints( |
| // TODO: uncomment after https://issues.apache.org/jira/browse/SIS-497 is fixed. |
| // Quantities.create(0.25, Units.PIXEL), |
| (accuracy > 0) ? Quantities.create(accuracy, Units.METRE) : null); |
| } catch (FactoryException e) { |
| recoverableException(e); |
| // Leave `changeOfCRS` to null. |
| } |
| } |
| |
| /** |
| * Creates the resampled image, then optionally applies an index color model. |
| * This method will compute the {@link MathTransform} steps from image coordinate system |
| * to display coordinate system if those steps have not already been computed. |
| * |
| * @param recoloredImage {@link #data} or a derived (typically recolored) image. |
| * @param objectiveToDisplay value of {@link PlanarCanvas#getObjectiveToDisplay()}. |
| * @param objectivePOI value of {@link PlanarCanvas#getPointOfInterest(boolean)} in objective CRS. |
| * @return image with operation applied and color ramp stretched. |
| * @throws TransformException if an error occurred in the use of "grid to CRS" transforms. |
| */ |
| public final RenderedImage resampleAndConvert(final RenderedImage recoloredImage, |
| final LinearTransform objectiveToDisplay, |
| final DirectPosition objectivePOI) |
| throws TransformException |
| { |
| /* |
| * Following transforms are computed when first needed after the new data have been specified, |
| * or after the objective CRS changed. If non-null, `objToCenterNoWrap` is the same transform |
| * than `objectiveToCenter` but without wraparound steps. A non-null value means that we need |
| * to check if wraparound step is really needed and replace `objectiveToCenter` if it appears |
| * to be unnecessary. |
| */ |
| MathTransform objToCenterNoWrap = null; |
| if (cornerToObjective == null || objectiveToCenter == null) { |
| cornerToObjective = dataGeometry.getGridToCRS(PixelInCell.CELL_CORNER); |
| objectiveToCenter = dataGeometry.getGridToCRS(PixelInCell.CELL_CENTER).inverse(); |
| if (changeOfCRS != null) { |
| DirectPosition median = getSourceMedian(); |
| MathTransform forward = changeOfCRS.getMathTransform(); |
| MathTransform inverse = forward.inverse(); |
| MathTransform nowrap = inverse; |
| try { |
| forward = applyWraparound(forward, median, objectivePOI, changeOfCRS.getTargetCRS()); |
| inverse = applyWraparound(inverse, objectivePOI, median, changeOfCRS.getSourceCRS()); |
| } catch (TransformException e) { |
| recoverableException(e); |
| } |
| if (inverse != nowrap) { |
| objToCenterNoWrap = MathTransforms.concatenate(nowrap, objectiveToCenter); |
| } |
| cornerToObjective = MathTransforms.concatenate(cornerToObjective, forward); |
| objectiveToCenter = MathTransforms.concatenate(inverse, objectiveToCenter); |
| } |
| } |
| /* |
| * Create a resampled image for current zoom level. If the image is zoomed, the resampled image bounds |
| * will be very large, potentially larger than 32 bit integer capacity (calculation done below clamps |
| * the result to 32 bit integer range). This is okay because only visible tiles will be created. |
| * |
| * NOTE: if user pans image close to integer range limit, a new resampled image will need to be computed |
| * for shifting away from integer overflow risk situation. This check is done by the caller. |
| */ |
| final LinearTransform inverse = objectiveToDisplay.inverse(); |
| displayToObjective = AffineTransforms2D.castOrCopy(inverse); |
| MathTransform cornerToDisplay = MathTransforms.concatenate(cornerToObjective, objectiveToDisplay); |
| MathTransform displayToCenter = MathTransforms.concatenate(inverse, objectiveToCenter); |
| /* |
| * If the source image is world-wide and if the transform involves a projection that cannot represent |
| * the whole world, then we need to clip the image to a domain supported by the map projection. |
| */ |
| final Rectangle bounds = ImageUtilities.getBounds(recoloredImage); |
| MathTransforms.getDomain(cornerToDisplay).ifPresent((domain) -> { |
| Shapes2D.intersect(bounds, domain, 0, 1); |
| }); |
| Shapes2D.transform(MathTransforms.bidimensional(cornerToDisplay), bounds, bounds); |
| /* |
| * Verify if wraparound is really necessary. We do this check because the `displayToCenter` transform |
| * may be used for every pixels, so it is worth to make that transform more efficient if possible. |
| */ |
| if (objToCenterNoWrap != null) { |
| final MathTransform nowrap = MathTransforms.concatenate(inverse, objToCenterNoWrap); |
| if (!isWraparoundNeeded(bounds, displayToCenter, nowrap)) { |
| objectiveToCenter = objToCenterNoWrap; |
| displayToCenter = nowrap; |
| } |
| } |
| /* |
| * Apply a map projection on the image, then convert the floating point results to integer values |
| * that we can use with IndexColorModel. |
| * |
| * TODO: if `colors` is null, instead of defaulting to `Colorizer.GRAYSCALE` we should get the colors |
| * from the current ColorModel. This work should be done in Colorizer by converting the ranges of |
| * sample values in source image to ranges of sample values in destination image, then query |
| * ColorModel.getRGB(Object) for increasing integer values in that range. |
| */ |
| if (CREATE_INDEX_COLOR_MODEL) { |
| final ColorModelType ct = ColorModelType.find(recoloredImage.getColorModel()); |
| if (ct.isSlow || (ct.useColorRamp && processor.getCategoryColors() != null)) { |
| return processor.visualize(recoloredImage, bounds, displayToCenter, dataRanges); |
| } |
| } |
| return processor.resample(recoloredImage, bounds, displayToCenter); |
| } |
| |
| /** |
| * Conversion or transformation from {@linkplain PlanarCanvas#getObjectiveCRS() objective CRS} to |
| * {@linkplain #data} CRS. This transform will include {@code WraparoundTransform} steps if needed. |
| * |
| * @param transform the transform to concatenate with a "wraparound" operation. |
| * @param sourceMedian point of interest in the <em>source</em> CRS of given transform. |
| * @param targetMedian point of interest after wraparound. |
| * @param targetCRS the target CRS of the given transform. |
| */ |
| private static MathTransform applyWraparound(final MathTransform transform, DirectPosition sourceMedian, |
| final DirectPosition targetMedian, final CoordinateReferenceSystem targetCRS) throws TransformException |
| { |
| if (targetMedian == null) { |
| return transform; |
| } |
| /* |
| * This method is invoked with `sourceMedian` expressed in the transform source CRS. |
| * But by contract, `WraparoundApplicator` needs that point in the transform target CRS. |
| */ |
| if (sourceMedian != null && !transform.isIdentity()) { |
| sourceMedian = transform.transform(sourceMedian, null); |
| } |
| return new WraparoundApplicator(sourceMedian, targetMedian, targetCRS.getCoordinateSystem()).forDomainOfUse(transform); |
| } |
| |
| /** |
| * Tests whether wraparound step seems necessary. This method transforms all corners and all centers |
| * of the given rectangle using the two specified transform. If the results differ by one pixel or more, |
| * the wraparound step is considered necessary. |
| * |
| * @param bounds rectangular coordinates of the display device, in pixels. |
| * @param reference transform from display coordinates to {@link #dataGeometry} cell coordinates. |
| * @param nowrap same as {@code reference} but with a wraparound step. Used as a reference. |
| * @return {@code true} if at least one coordinate is distant from the reference coordinate by at least one pixel. |
| * |
| * @see org.apache.sis.coverage.grid.CoordinateOperationFinder#isWraparoundNeeded |
| */ |
| private static boolean isWraparoundNeeded(final Rectangle bounds, |
| final MathTransform reference, final MathTransform nowrap) throws TransformException |
| { |
| final int numPts = 9; |
| final int srcDim = nowrap.getSourceDimensions(); // Should always be 2, but we are paranoiac. |
| final int tgtDim = nowrap.getTargetDimensions(); // Idem. |
| final double[] source = new double[srcDim * numPts]; |
| final double[] target = new double[tgtDim * numPts]; |
| for (int pi=0; pi<numPts; pi++) { |
| final double x, y; |
| switch (pi % 3) { |
| case 0: x = bounds.getMinX(); break; |
| case 1: x = bounds.getMaxX(); break; |
| default: x = bounds.getCenterX(); break; |
| } |
| switch (pi / 3) { |
| case 0: y = bounds.getMinY(); break; |
| case 1: y = bounds.getMaxY(); break; |
| default: y = bounds.getCenterY(); break; |
| } |
| final int i = pi * srcDim; |
| source[i ] = x; |
| source[i+1] = y; |
| } |
| nowrap .transform(source, 0, target, 0, numPts); |
| reference.transform(source, 0, source, 0, numPts); |
| for (int i=0; i<target.length; i++) { |
| final double r = source[i]; |
| if (!(Math.abs(target[i] - r) < 1) && Double.isFinite(r)) { // Use `!` for catching NaN. |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * Computes immediately, possibly using many threads, the tiles that are going to be displayed. |
| * The returned instance should be used only for current rendering event; it should not be cached. |
| * |
| * @param resampledImage the image computed by {@link #resampleAndConvert resampleAndConvert(…)}. |
| * @param resampledToDisplay the transform computed by {@link #getTransform(LinearTransform)}. |
| * @param displayBounds size and location of the display device (plus margin), in pixel units. |
| * @return a temporary image with tiles intersecting the display region already computed. |
| */ |
| public final RenderedImage prefetch(final RenderedImage resampledImage, final AffineTransform resampledToDisplay, |
| final Envelope2D displayBounds) |
| { |
| final Rectangle areaOfInterest; |
| try { |
| areaOfInterest = (Rectangle) AffineTransforms2D.transform(resampledToDisplay.createInverse(), displayBounds, new Rectangle()); |
| } catch (NoninvertibleTransformException e) { |
| recoverableException(e); |
| return resampledImage; |
| } |
| return processor.prefetch(resampledImage, areaOfInterest); |
| } |
| |
| /** |
| * Gets the transform to use for painting the resampled image. If the image to draw is an instance of |
| * {@link BufferedImage}, then it is okay to have any transform. However for other kinds of image, |
| * it is important that the transform has scale factors of 1 and integer translations because Java2D |
| * has an optimization which avoid to copy the whole data only for that case. |
| * |
| * @param objectiveToDisplay the transform from objective CRS to canvas coordinates. |
| * @return transform from resampled image to canvas (display) coordinates. |
| */ |
| public final AffineTransform getTransform(final LinearTransform objectiveToDisplay) { |
| if (displayToObjective == null) { |
| return new AffineTransform(); |
| } |
| AffineTransform resampledToDisplay = AffineTransforms2D.castOrCopy(objectiveToDisplay); |
| if (resampledToDisplay == objectiveToDisplay) { |
| resampledToDisplay = new AffineTransform(resampledToDisplay); |
| } |
| resampledToDisplay.concatenate(displayToObjective); |
| ImageUtilities.roundIfAlmostInteger(resampledToDisplay); |
| return resampledToDisplay; |
| } |
| |
| /** |
| * Returns an estimation of the size of data pixels, in objective CRS. |
| * |
| * @param objectivePOI point of interest in objective CRS. |
| * @return an estimation of the source pixel size at the given location. |
| */ |
| public final float getDataPixelSize(final DirectPosition objectivePOI) { |
| if (objectiveToCenter != null) try { |
| final Matrix d = objectiveToCenter.derivative(objectivePOI); |
| double sum = 0; |
| for (int j=d.getNumRow(); --j >= 0;) { |
| for (int i=d.getNumCol(); --i >= 0;) { |
| final double v = d.getElement(j, i); |
| sum += v*v; |
| } |
| } |
| final float r = (float) (1 / Math.sqrt(sum)); |
| if (r > 0 && r != Float.POSITIVE_INFINITY) { |
| return r; |
| } |
| } catch (TransformException e) { |
| recoverableException(e); |
| } |
| return 0; |
| } |
| |
| /** |
| * Returns the conversion from {@link #data} pixel coordinates to |
| * {@linkplain PlanarCanvas#getObjectiveCRS() objective CRS}. |
| * |
| * @param anchor whether the conversion should start from pixel corner or pixel center. |
| * @return conversion from data pixel coordinates to objective CRS. |
| */ |
| public final MathTransform getDataToObjective(final PixelInCell anchor) { |
| return PixelTranslation.translate(cornerToObjective, PixelInCell.CELL_CORNER, anchor); |
| } |
| |
| /** |
| * Converts the given bounds from objective coordinates to pixel coordinates in the source coverage. |
| * |
| * @param bounds objective coordinates. |
| * @return data coverage cell coordinates (in pixels), or {@code null} if unknown. |
| * @throws TransformException if the bounds cannot be transformed. |
| */ |
| public final Rectangle objectiveToData(final Rectangle2D bounds) throws TransformException { |
| if (objectiveToCenter == null) return null; |
| return (Rectangle) Shapes2D.transform(MathTransforms.bidimensional(objectiveToCenter), bounds, new Rectangle()); |
| } |
| |
| /** |
| * Returns whether {@link #dataGeometry} or {@link #objectiveToCenter} changed since a previous rendering. |
| * This is used for information purposes only. |
| * |
| * @param previous previous instance of {@code RenderingData}. |
| * @return whether this {@code RenderingData} does a different rendering than previous {@code RenderingData}. |
| */ |
| public final boolean hasChanged(final RenderingData previous) { |
| /* |
| * Really !=, not Object.equals(Object), because we rely on new instances to be created |
| * (even if equal) as a way to detect that cached values have not been reused. |
| */ |
| return (previous.dataGeometry != dataGeometry) || (previous.objectiveToCenter != objectiveToCenter); |
| } |
| |
| /** |
| * Invoked when an exception occurred while computing a transform but the painting process can continue. |
| * This method pretends that the warning come from {@link PlanarCanvas} class since it is the public API. |
| */ |
| private static void recoverableException(final Exception e) { |
| Logging.recoverableException(LOGGER, PlanarCanvas.class, "render", e); |
| } |
| |
| /** |
| * Creates new rendering data initialized to a copy of this instance. |
| */ |
| @Override |
| public RenderingData clone() { |
| try { |
| return (RenderingData) super.clone(); |
| } catch (CloneNotSupportedException e) { |
| throw new AssertionError(e); |
| } |
| } |
| |
| /** |
| * Returns a string representation for debugging purposes. |
| * The string content may change in any future version. |
| */ |
| @Override |
| public String toString() { |
| final String lineSeparator = System.lineSeparator(); |
| final StringBuilder buffer = new StringBuilder(6000); |
| final TableAppender table = new TableAppender(buffer); |
| table.setMultiLinesCells(true); |
| try { |
| table.nextLine('═'); |
| table.append("Geometry of source coverage:").append(lineSeparator) |
| .append(String.valueOf(dataGeometry)) |
| .appendHorizontalSeparator(); |
| table.append("Pixel corners to objective CRS:").append(lineSeparator) |
| .append(String.valueOf(cornerToObjective)) |
| .appendHorizontalSeparator(); |
| table.append("Median in data CRS:").append(lineSeparator) |
| .append(String.valueOf(getSourceMedian())) |
| .nextLine(); |
| table.nextLine('═'); |
| table.flush(); |
| } catch (IOException e) { |
| throw new UncheckedIOException(e); // Should never happen since we are writing to `StringBuilder`. |
| } |
| return buffer.toString(); |
| } |
| } |