blob: abb8792332b4d58c5fcac2b4d08a87b8a1af5002 [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.internal.processing.isoline;
import java.util.AbstractList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.EnumMap;
import java.util.TreeMap;
import java.util.NavigableMap;
import java.util.function.BiConsumer;
import java.util.concurrent.Future;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.CompletionException;
import java.awt.Shape;
import java.awt.geom.Path2D;
import java.awt.image.RenderedImage;
import org.opengis.coverage.grid.SequenceType;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.TransformException;
import org.apache.sis.internal.processing.image.TiledProcess;
import org.apache.sis.image.PixelIterator;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.ArraysExt;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.Debug;
import static org.apache.sis.internal.processing.isoline.Tracer.UPPER_LEFT;
import static org.apache.sis.internal.processing.isoline.Tracer.UPPER_RIGHT;
import static org.apache.sis.internal.processing.isoline.Tracer.LOWER_RIGHT;
/**
* Creates isolines at specified levels from grid values provided in a {@link RenderedImage}.
* Isolines are created by calls to the {@link #generate generate(…)} static method.
*
* @author Johann Sorel (Geomatys)
* @author Martin Desruisseaux (Geomatys)
* @version 1.3
*
* @see <a href="https://en.wikipedia.org/wiki/Marching_squares">Marching squares on Wikipedia</a>
*
* @since 1.1
* @module
*/
public final class Isolines {
/**
* Isoline data for each level, sorted in ascending order of {@link Tracer.Level#value}.
*/
private final Tracer.Level[] levels;
/**
* A consumer to notify about the current state of isoline generation, or {@code null} if none.
* This is used for debugging purposes only. This field is temporarily set to a non-null value
* when using {@code StepsViewer} (in test package) for following an isoline generation step
* by step.
*/
@Debug
private static final BiConsumer<String,Isolines> LISTENER = null;
/**
* Creates an initially empty set of isolines for the given levels. The given {@code values}
* array should be one of the arrays validated by {@link #cloneAndSort(double[][])}.
*/
private Isolines(final Tracer tracer, final int band, final double[] values, final int width) {
levels = new Tracer.Level[values.length];
for (int i=0; i<values.length; i++) {
levels[i] = tracer.new Level(band, values[i], width);
}
}
/**
* Ensures that the given arrays are sorted and contains no NaN value.
*/
private static double[][] cloneAndSort(double[][] levels) {
levels.clone();
for (int j=0; j<levels.length; j++) {
double[] values = levels[j];
ArgumentChecks.ensureNonNullElement("levels", j, values);
values = values.clone();
Arrays.sort(values);
int n = values.length;
while (n > 0 && Double.isNaN(values[n-1])) n--;
for (int i=n; --i>0;) {
if (values[i] == values[i-1]) {
// Remove duplicated elements. May replace -0 by +0.
System.arraycopy(values, i, values, i-1, n-- - i);
}
}
levels[j] = ArraysExt.resize(values, n);
}
return levels;
}
/**
* Sets the specified bit on {@link Tracer.Level#isDataAbove} for all levels lower than given value.
*
* <h4>How strict equalities are handled</h4>
* Sample values exactly equal to the isoline value are handled as if they were greater. It does not matter
* for interpolations: we could flip this convention randomly, the interpolated points would still the same.
* However it could change the way line segments are assembled in a single polyline, but the algorithm stay
* consistent if we always apply the same rule for all points.
*
* <h4>How NaN values are handled</h4>
* {@link Double#NaN} sample values are considered higher than any level values. The algorithm does not need
* special attention for those values; bit patterns will be computed in a consistent way, and interpolations
* will produce NaN values and append them to polylines like real values. Those NaN values will be filtered
* out in the final stage, when copying coordinates in {@link Path2D} objects.
*
* @param value a sample values from the image.
* @param bit {@value Tracer#UPPER_LEFT}, {@value Tracer#UPPER_RIGHT},
* {@value Tracer#LOWER_LEFT} or {@value Tracer#LOWER_RIGHT}.
*
* @see Tracer.Level#nextColumn()
*/
private void setMaskBit(final double value, final int bit) {
for (final Tracer.Level level : levels) {
if (level.value > value) break; // See above javadoc for NaN handling.
level.isDataAbove |= bit;
}
}
/**
* Generates isolines at the specified levels computed from data provided by the given image.
* Isolines will be created for every bands in the given image.
* This method performs all computation sequentially in current thread.
*
* @param data image providing source values.
* @param levels values for which to compute isolines. An array should be provided for each band.
* If there is more bands than {@code levels.length}, the last array is reused for
* all remaining bands.
* @param gridToCRS transform from pixel coordinates to geometry coordinates, or {@code null} if none.
* Integer source coordinates are located at pixel centers.
* @return the isolines for each band in the given image.
* @throws TransformException if an interpolated point can not be transformed using the given transform.
*/
public static Isolines[] generate(final RenderedImage data, final double[][] levels,
final MathTransform gridToCRS) throws TransformException
{
ArgumentChecks.ensureNonNull("data", data);
ArgumentChecks.ensureNonNull("levels", levels);
return flush(generate(iterators().create(data), cloneAndSort(levels), gridToCRS));
}
/**
* Generates isolines in background using an arbitrary amount of processors.
* This method returns immediately (i.e. the current thread is not used for isoline computation).
* The result will become available at a later time in the {@link Future} object.
*
* @param data image providing source values.
* @param levels values for which to compute isolines. An array should be provided for each band.
* If there is more bands than {@code levels.length}, the last array is reused for
* all remaining bands.
* @param gridToCRS transform from pixel coordinates to geometry coordinates, or {@code null} if none.
* Integer source coordinates are located at pixel centers.
* @return a {@code Future} representing pending completion of isoline computation.
*/
public static Future<Isolines[]> parallelGenerate(final RenderedImage data, final double[][] levels,
final MathTransform gridToCRS)
{
ArgumentChecks.ensureNonNull("data", data);
ArgumentChecks.ensureNonNull("levels", levels);
return new Process(data, cloneAndSort(levels), gridToCRS).execute();
}
/**
* Returns a provider of {@link PixelIterator} suitable to isoline computations.
* It is critical that iterators use {@link SequenceType#LINEAR} iterator order.
*/
private static PixelIterator.Builder iterators() {
return new PixelIterator.Builder().setIteratorOrder(SequenceType.LINEAR);
}
/**
* Creates all polylines that were still pending. This method should be the very last step,
* when all other computations finished. Pending polylines are sequences of points not yet
* stored in geometry objects because they were waiting to see if additional points would
* close them as polygons. This {@code flush()} method is invoked when we know that it will
* not be the case because there are no more points to add.
*
* @param isolines the isolines for which to write pending polylines.
* @return the {@code isolines} array, returned for convenience.
* @throws TransformException if an error occurred during polylines creation.
*/
private static Isolines[] flush(final Isolines[] isolines) throws TransformException {
for (final Isolines isoline : isolines) {
for (final Tracer.Level level : isoline.levels) {
level.flush();
}
}
return isolines;
}
/**
* Wraps {@code Isolines.generate(…)} calculation in a process for parallel execution.
* The source image is divided in sub-region and the isolines in each sub-region will
* be computed in a different thread.
*/
private static final class Process extends TiledProcess<Isolines[]> {
/**
* Values for which to compute isolines. An array should be provided for each band.
* If there is more bands than {@code levels.length}, the last array is reused for
* all remaining bands.
*
* @see #cloneAndSort(double[][])
*/
private final double[][] levels;
/**
* Transform from image upper left corner (in pixel coordinates) to geometry coordinates.
*/
private final MathTransform gridToCRS;
/**
* Creates a process for parallel isoline computation.
*
* @param data image providing source values.
* @param levels values for which to compute isolines.
* @param gridToCRS transform from pixel coordinates to geometry coordinates, or {@code null} if none.
*/
Process(final RenderedImage data, final double[][] levels, final MathTransform gridToCRS) {
super(data, 1, 1, iterators());
this.levels = levels;
this.gridToCRS = gridToCRS;
}
/**
* Invoked by {@link TiledProcess} for creating a sub-task
* doing isoline computation on a sub-region of the image.
*/
@Override
protected Task createSubTask() {
return new Tile();
}
/**
* A sub-task doing isoline computation on a sub-region of the image.
* The region is determined by the {@link #iterator}.
*/
private final class Tile extends Task {
/** Isolines computed in the sub-region of this sub-task. */
private Isolines[] isolines;
/** Creates a new sub-task. */
Tile() {
}
/** Invoked in a background thread for performing isoline computation. */
@Override protected void execute() throws TransformException {
isolines = generate(iterator, levels, gridToCRS);
}
/** Invoked in a background thread for merging results of two sub-tasks. */
@Override protected void merge(final Task neighbor) throws TransformException {
for (int i=0; i<isolines.length; i++) {
final Isolines target = isolines[i];
final Isolines source = ((Tile) neighbor).isolines[i];
for (int j=0; j<target.levels.length; j++) {
target.levels[j].merge(source.levels[j]);
}
}
}
/** Invoked on the last sub-task (after all merges) for getting final result. */
@Override protected Isolines[] result() throws TransformException {
return flush(isolines);
}
}
}
/**
* Generates isolines for the specified levels in the region traversed by the given iterator.
* This is the implementation of {@link #generate(RenderedImage, double[][], MathTransform)}
* but potentially on a sub-region for parallel "fork-join" execution.
*/
private static Isolines[] generate(final PixelIterator iterator, final double[][] levels,
final MathTransform gridToCRS) throws TransformException
{
/*
* Prepares a window of size 2×2 pixels over pixel values. Window elements are traversed
* by incrementing indices in following order: band, column, row. The window content will
* be written in this method and read by Tracer.
*/
final int numBands = iterator.getNumBands();
final double[] window = new double[numBands * 4];
final Tracer tracer = new Tracer(window, numBands, iterator.getDomain(), gridToCRS);
/*
* Prepare the set of isolines for each band in the image.
* The number of cells on the horizontal axis is one less
* than the image width.
*/
final int width = iterator.getDomain().width - 1;
final Isolines[] isolines = new Isolines[numBands];
{ // For keeping variable locale.
double[] levelValues = ArraysExt.EMPTY_DOUBLE;
for (int b=0; b<numBands; b++) {
if (b < levels.length) {
levelValues = levels[b];
ArgumentChecks.ensureNonNullElement("levels", b, levelValues);
levelValues = levelValues.clone();
}
isolines[b] = new Isolines(tracer, b, levelValues, width);
}
}
/*
* Cache sample values on the top row. Those values are reused by the row just below row
* of cached values. This array is updated during iteration with values of current cell.
*/
final double[] pixelValues = new double[numBands];
final double[] valuesOnPreviousRow = new double[numBands * (width+1)];
for (int i=0; i < valuesOnPreviousRow.length; i += numBands) {
if (!iterator.next()) return isolines;
System.arraycopy(iterator.getPixel(pixelValues), 0, valuesOnPreviousRow, i, numBands);
}
/*
* Compute isolines for all bands. Iteration over bands must be the innermost loop because
* data layout in buffer is band index varying fastest, then column index, then row index.
*/
final int twoPixels = numBands * 2;
final int lastPixel = numBands * 3;
abort: while (iterator.next()) {
/*
* Process the first cell of a new row:
*
* - Get values on the 4 corners.
* - Save value of lower-left corner for use by next row.
* - Initialize `Tracer.Level.isDataAbove` bits for all levels.
* - Interpolate the first cell.
*/
System.arraycopy(valuesOnPreviousRow, 0, window, 0, twoPixels);
System.arraycopy(iterator.getPixel(pixelValues), 0, window, twoPixels, numBands);
if (!iterator.next()) break;
System.arraycopy(iterator.getPixel(pixelValues), 0, window, lastPixel, numBands);
System.arraycopy(window, twoPixels, valuesOnPreviousRow, 0, twoPixels);
for (int i=0, flag = UPPER_LEFT; flag <= LOWER_RIGHT; flag <<= 1) {
for (int b=0; b<numBands; b++) { // Must be the inner loop (see above comment).
isolines[b].setMaskBit(window[i++], flag);
}
}
for (final Isolines iso : isolines) {
for (final Tracer.Level level : iso.levels) {
level.interpolate();
}
}
/*
* Process all pixels on a row after the first column. We can reuse the bitmask of previous
* iteration with a simple bit shift operation. This is done by the `nextColumn()` call.
* The series for `System.arraycopy(…)` calls are for moving 3 pixel values of previous
* iteration that we can reuse, then fetch the only new value from the iterator.
*/
for (tracer.x = 1; tracer.x < width; tracer.x++) {
final int offsetOnPreviousRow = (tracer.x + 1) * numBands;
if (!iterator.next()) break abort; // Should never abort
if (numBands == 1) { // Optimization for a common case
window[2] = window[3]; // Lower-right → Lower-left
window[0] = window[1]; // Upper-right → Upper-left
window[1] = valuesOnPreviousRow[offsetOnPreviousRow]; // Take upper-right from previous row
window[3] = valuesOnPreviousRow[offsetOnPreviousRow] = iterator.getSampleDouble(0);
} else {
System.arraycopy(window, numBands, window, 0, numBands); // Upper-right → Upper-left
System.arraycopy(window, lastPixel, window, twoPixels, numBands); // Lower-right → Lower-left
System.arraycopy(valuesOnPreviousRow, offsetOnPreviousRow, window, numBands, numBands);
System.arraycopy(iterator.getPixel(pixelValues), 0, window, lastPixel, numBands);
System.arraycopy(window, lastPixel, valuesOnPreviousRow, offsetOnPreviousRow, numBands);
}
for (int b=0; b<numBands; b++) {
final Isolines iso = isolines[b];
for (final Tracer.Level level : iso.levels) {
level.nextColumn();
}
iso.setMaskBit(window[numBands + b], UPPER_RIGHT);
iso.setMaskBit(window[lastPixel + b], LOWER_RIGHT);
for (final Tracer.Level level : iso.levels) {
level.interpolate();
}
}
}
/*
* Finished iteration on a row. Clear flags and update position before to move to next row.
* If there is listeners to notify (for debugging purposes), notify them.
*/
for (int b=0; b<numBands; b++) {
for (final Tracer.Level level : isolines[b].levels) {
level.finishedRow();
}
if (LISTENER != null) {
final int y = tracer.y;
final int h = iterator.getDomain().height;
LISTENER.accept(String.format("After row %d of %d (%3.1f%%)", y, h, 100f*y/h), isolines[b]);
}
}
tracer.x = 0;
tracer.y++;
}
/*
* Finished iteration over the whole image.
*/
for (int b=0; b<numBands; b++) {
for (final Tracer.Level level : isolines[b].levels) {
level.finish();
}
if (LISTENER != null) {
LISTENER.accept("Finished band " + b, isolines[b]);
}
}
return isolines;
}
/**
* Returns the polylines for each level specified to the {@link #generate generate(…)} method.
*
* @return the polylines for each level.
*/
public final NavigableMap<Double,Shape> polylines() {
final TreeMap<Double,Shape> paths = new TreeMap<>();
for (final Tracer.Level level : levels) {
final Shape path = level.shape;
if (path != null) {
paths.put(level.value, path);
}
}
return paths;
}
/**
* Returns the isolines for each band, then for each values in the band.
*
* @param isolines result of {@code generate(…)} or {@code parallelGenerate(…)} method call.
* @return isoline shapes for each values in each band.
*/
private static NavigableMap<Double,Shape>[] toArray(final Isolines[] isolines) {
@SuppressWarnings({"rawtypes", "unchecked"})
final NavigableMap<Double,Shape>[] result = new NavigableMap[isolines.length];
for (int i=0; i<result.length; i++) {
result[i] = isolines[i].polylines();
}
return result;
}
/**
* Returns the isolines for each band, then for each values in the band.
*
* @param isolines result of {@code generate(…)} or {@code parallelGenerate(…)} method call.
* @return isoline shapes for each values in each band.
*/
public static List<NavigableMap<Double,Shape>> toList(final Isolines[] isolines) {
return Arrays.asList(toArray(isolines));
}
/**
* Returns deferred isolines for each band, then for each values in the band.
* The {@link Future} result is requested the first time that {@link List#get(int)} is invoked.
*
* @param isolines result of {@code generate(…)} or {@code parallelGenerate(…)} method call.
* @return isoline shapes for each values in each band.
*/
public static List<NavigableMap<Double,Shape>> toList(final Future<Isolines[]> isolines) {
return new Result(isolines);
}
/**
* Deferred isoline result, created when computation is continuing in background.
* The {@link Future} result is requested the first time that {@link #get(int)} is invoked.
*/
private static final class Result extends AbstractList<NavigableMap<Double,Shape>> {
/** The task computing isolines result. Reset to {@code null} when no longer needed. */
private Future<Isolines[]> task;
/** The result of {@link Future#get()} fetched when first needed. */
private NavigableMap<Double,Shape>[] isolines;
/** Creates a new list for the given future isolines. */
Result(final Future<Isolines[]> task) {
this.task = task;
}
/** Fetches the isolines from the {@link Future} if not already done. */
@SuppressWarnings("ReturnOfCollectionOrArrayField")
private NavigableMap<Double,Shape>[] isolines() {
if (isolines == null) {
if (task == null) {
throw new CompletionException(Errors.format(Errors.Keys.BackgroundComputationFailed), null);
}
try {
isolines = Isolines.toArray(task.get());
task = null;
} catch (InterruptedException e) {
// Do not clear `task`: the result may become available later.
throw new CompletionException(Errors.format(Errors.Keys.InterruptedWhileWaitingResult), e);
} catch (ExecutionException e) {
task = null;
throw new CompletionException(Errors.format(Errors.Keys.BackgroundComputationFailed), e.getCause());
}
}
return isolines;
}
/** Returns the list length, which is the number of bands. */
@Override public int size() {
return isolines().length;
}
/** Returns the isolines in the given band. */
@Override public NavigableMap<Double,Shape> get(final int band) {
return isolines()[band];
}
/** Returns the list content as an array. */
@Override public Object[] toArray() {
return isolines().clone();
}
}
/**
* Returns the pixel coordinates of all level, for debugging purposes only.
* The {@link #gridToCRS} transform is <em>not</em> applied by this method.
* For avoiding confusing behavior, that transform should be null.
*
* @return the pixel coordinates.
*/
@Debug
final Map<PolylineStage,Path2D> toRawPath() {
final Map<PolylineStage,Path2D> appendTo = new EnumMap<>(PolylineStage.class);
for (final Tracer.Level level : levels) {
level.toRawPath(appendTo);
}
return appendTo;
}
}