/* * Geotoolkit.org - An Open Source Java GIS Toolkit * http://www.geotoolkit.org * * (C) 2014, Geomatys * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; * version 2.1 of the License. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. */ package org.geotoolkit.processing.coverage.resample; import java.awt.*; import java.awt.image.*; import java.io.File; import java.io.IOException; import java.util.*; import java.util.List; import java.util.concurrent.*; import java.util.logging.Level; import java.util.logging.Logger; import javax.imageio.*; import org.apache.sis.geometry.GeneralEnvelope; import org.apache.sis.util.ArgumentChecks; import org.apache.sis.util.logging.Logging; import org.apache.sis.geometry.Envelopes; import org.geotoolkit.image.interpolation.Interpolation; import org.geotoolkit.image.interpolation.InterpolationCase; import org.geotoolkit.image.interpolation.Resample; import org.geotoolkit.image.io.large.LargeCache; import org.geotoolkit.image.io.large.LargeRenderedImage; import org.geotoolkit.image.iterator.PixelIterator; import org.geotoolkit.image.iterator.PixelIteratorFactory; import org.geotoolkit.nio.IOUtilities; import org.geotoolkit.parameter.Parameters; import org.geotoolkit.processing.AbstractProcess; import org.geotoolkit.process.ProcessDescriptor; import org.geotoolkit.process.ProcessException; import org.apache.sis.referencing.operation.transform.MathTransforms; import org.apache.sis.internal.referencing.j2d.AffineTransform2D; import org.opengis.parameter.ParameterValueGroup; import org.opengis.referencing.operation.MathTransform; /** * A resample operation using a provided MathTransform to convert a source coverage. * * The process is designed to use multi-threading capacity, and not overload in memory. * To do such things, we need an {@link ImageWriter} supporting {@link ImageWriter#canReplacePixels(int) } operation. * * Here is how it works : * 1 - Using specified parameter {@link IOResampleDescriptor#TILE_SIZE}, we prepare tiles from output image, and put them in a queue, waiting for resampling. * 2 - We create a fix number of threads (as specified by {@link IOResampleDescriptor#THREAD_COUNT}), which will poll data from above queue, and resample it. * 3 - Each computed tile is put into another queue, waiting to be written. * 4 - We've got a thread for image writing. It's listening on output queue, and write each ready tile inserted into it. * * @author Alexis Manin (Geomatys) */ public class IOResampleProcess extends AbstractProcess { private static final Logger LOGGER = Logging.getLogger("org.geotoolkit.processing.coverage.resample"); private static final int LANCZOS_WINDOW = 2; /** * Timeout parameters for queue transactions. */ public static final int TIMEOUT = 100; private static final TimeUnit TIMEOUT_UNIT = TimeUnit.SECONDS; /** A default size to use if user did not specified a tile size. */ private static final Dimension DEFAULT_TILE_SIZE = new Dimension(256, 256); /** Limit for queue capacity. */ private static final int QUEUE_SIZE = 20; /** * A queue to store strips we want to resample. */ private final LinkedBlockingQueue<Rectangle> resamplingQueue = new LinkedBlockingQueue<>(QUEUE_SIZE); /** * A queue to store resampled strips we must write. We give priority to the tiles which are closer to upper-left corner of the output image. */ private final PriorityBlockingQueue<Map.Entry<Point, RenderedImage>> writingQueue = new PriorityBlockingQueue<>(QUEUE_SIZE, new Comparator<Map.Entry<Point, RenderedImage>>() { @Override public int compare(Map.Entry<Point, RenderedImage> o1, Map.Entry<Point, RenderedImage> o2) { if (o1 instanceof EndOfFile) { return -1; } else if (o2 instanceof EndOfFile) { return 1; } else { Point first = o1.getKey(); Point second = o2.getKey(); final int linePriority = second.x - first.x; // If the two point are on the same line, we must know which is the most advanced on it. return (linePriority != 0) ? linePriority : second.y - first.y; } } }); private Integer threadNumber; public IOResampleProcess(ProcessDescriptor desc, ParameterValueGroup input) { super(desc, input); } @Override protected void execute() throws ProcessException { final List<Long> execTimes = new ArrayList<>(); execTimes.add(System.currentTimeMillis()); /* * CHECK INPUTS */ final ImageReader inImage = (ImageReader) inputParameters.parameter("image").getValue(); final MathTransform operator = (MathTransform) inputParameters.parameter("operation").getValue(); final String interpolation = (String) inputParameters.parameter("interpolation").getValue(); threadNumber = Parameters.value(IOResampleDescriptor.THREAD_COUNT, inputParameters); if (threadNumber == null || threadNumber != threadNumber || threadNumber < 1) { threadNumber = Math.min(5, Math.max(2, Runtime.getRuntime().availableProcessors() / 2)); } Dimension tileSize = Parameters.value(IOResampleDescriptor.TILE_SIZE, inputParameters); if (tileSize == null || tileSize.width <= 0 || tileSize.height <= 0) { tileSize = DEFAULT_TILE_SIZE; } InterpolationCase toUse = InterpolationCase.BILINEAR; for (final InterpolationCase icase : InterpolationCase.values()) { if (icase.name().equalsIgnoreCase(interpolation)) { toUse = icase; break; } } try { /* * INITIALIZE IO OBJECTS */ // Prepare the input image. We use a LargeRenderedImage, because we'll get random access to pixels, and if it's too big, we need a cache system. final ImageTypeSpecifier rawImageType = inImage.getRawImageType(0); final ColorModel colorModel = rawImageType.getColorModel(); final LargeRenderedImage rawImage = new LargeRenderedImage(inImage, 0, LargeCache.getInstance(), tileSize); /* * Prepare output image for writing. If no file location is given, we create a new TIF temporary file to * store result. If user did not specified size for target image, we compute one from given transformation. */ Integer width = Parameters.value(IOResampleDescriptor.OUT_WIDTH, inputParameters); Integer height = Parameters.value(IOResampleDescriptor.OUT_HEIGHT, inputParameters); if (width == null || height == null || width <= 0 || height <= 0) { final GeneralEnvelope transformed = Envelopes.transform(operator, new GeneralEnvelope(new double[]{0, 0}, new double[]{rawImage.getWidth(), rawImage.getHeight()})); width = (int) Math.ceil(transformed.getSpan(0)); height = (int) Math.ceil(transformed.getSpan(1)); } if (width <= 0 && height <= 0) { throw new ProcessException("Impossible to define a proper size for output image.", this, null); } File output; String imageFormat = null; try { final String outLocation = (String) inputParameters.parameter("outputLocation").getValue(); output = new File(outLocation); if (output.isDirectory()) { output = new File(output, "GR_" + UUID.randomUUID() + ".tif"); } else { imageFormat = IOUtilities.extension(output); } } catch (Exception e) { output = File.createTempFile("GR_" + UUID.randomUUID(), ".tif"); } if (imageFormat == null || imageFormat.isEmpty()) { imageFormat = "tif"; } ImageWriter writer = getWriter(imageFormat, output); if (writer == null) { throw new IOException("No fitting image writer can be found on the system for format : " + imageFormat + ". Note that orthorectification process needs a writer capable of writing images piece by piece."); } // We want to create a tiled image as output. ImageWriteParam outputParam = writer.getDefaultWriteParam(); outputParam.setTilingMode(ImageWriteParam.MODE_EXPLICIT); outputParam.setTiling(tileSize.width, tileSize.height, 0, 0); writer.prepareWriteEmpty(null, rawImageType, width, height, null, null, outputParam); writer.prepareReplacePixels(0, null); final int bandNumber = colorModel.getNumComponents(); final double[] defaultPixelValue = new double[bandNumber]; Arrays.fill(defaultPixelValue, Double.NaN); // Prepare multi-threading service. We create as many threads as user asked for resampling, plus one for strip writing. final ArrayList<Future> runnableResults = new ArrayList<>(); final ExecutorService resampleService = Executors.newFixedThreadPool(threadNumber); for (int threadCounter = 0; threadCounter < threadNumber; threadCounter++) { // Duplicate iterators and interpolator because they're not thread-safe. final PixelIterator it = PixelIteratorFactory.createDefaultIterator(rawImage); final Interpolation interpol = Interpolation.create(it, toUse, LANCZOS_WINDOW); runnableResults.add( resampleService.submit(new ResampleThread(operator, interpol, defaultPixelValue, colorModel))); } final ExecutorService writerService = Executors.newSingleThreadExecutor(); runnableResults.add( writerService.submit(new Writer(writer))); LOGGER.log(Level.INFO, "We'll now start resampling. Output image dimension : width " + width + " px | height " + height + " px."); execTimes.add(System.currentTimeMillis()); // once we've submit all tiles to compute, we just have to wait resamplers to end their work. After that, // writing queue should be full. We will add the end trigger. populateResamplingQueue(width, height, tileSize); resampleService.shutdown(); resampleService.awaitTermination(1, TimeUnit.DAYS); poisonWritingQueue(); writerService.shutdown(); writerService.awaitTermination(1, TimeUnit.DAYS); // Check possible thread errors : for (Future result : runnableResults) { try { result.get(TIMEOUT, TIMEOUT_UNIT); } catch (ExecutionException e) { if (e.getCause() != null) { throw e.getCause(); } else { throw e; } } } writer.endReplacePixels(); writer.endWriteEmpty(); writer.setOutput(null); writer.dispose(); Parameters.getOrCreate(IOResampleDescriptor.OUT_COVERAGE, outputParameters).setValue(output); LOGGER.log(Level.INFO, "Data preparation lasts " + (execTimes.get(1) - execTimes.get(0)) + " ms\n"); LOGGER.log(Level.INFO, "Resample lasts " + (System.currentTimeMillis() - execTimes.get(1)) + " ms\n"); } catch (Throwable e) { poisonResamplingQueue(); poisonWritingQueue(); throw new ProcessException(e.getLocalizedMessage(), this, e); } } /** * Fill the list of strips to resample. For now, the methods creates strips to fill, maybe in the future we could replace it * to work with tiles. * * /!\ WARNING : All the process is based on the fact that "Poisonous objects" will be put in the queue to notify threads * there is no more data to treat. Don't forget it if you modify this method. * * @param imageWidth Total width to fill * @param imageHeight Total height to fill * @param tileSize The {@link Dimension} we want for a single tile. */ private void populateResamplingQueue(final int imageWidth, final int imageHeight, final Dimension tileSize) throws InterruptedException { int tileHeight, tileWidth; // Iterate through upper left corner of tiles. for (int y = 0; y < imageHeight; y += tileSize.height) { if (y + tileSize.height > imageHeight) { tileHeight = imageHeight - y; } else { tileHeight = tileSize.height; } for (int x = 0; x < imageWidth; x += tileSize.width) { if (x + tileSize.width > imageWidth) { tileWidth = imageWidth - x; } else { tileWidth = tileSize.width; } final Rectangle resampleZone = new Rectangle(x, y, tileWidth, tileHeight); resamplingQueue.offer(resampleZone, TIMEOUT, TIMEOUT_UNIT); } } // insert poison objects, so our threads will know it's over when they get it. poisonResamplingQueue(); } private void poisonResamplingQueue() { for (int i = 0; i <= threadNumber; i++) { try { resamplingQueue.offer(new EmptyBox(), TIMEOUT, TIMEOUT_UNIT); } catch (InterruptedException e) { LOGGER.log(Level.INFO, "Process interrupted !"); Thread.currentThread().interrupt(); return; } } } private void poisonWritingQueue() { writingQueue.offer(new EndOfFile<Point, RenderedImage>(), TIMEOUT, TIMEOUT_UNIT); } /** * Try to get an image writer which can process output image piece by piece. * * @param extension The extension of the output image file, used to get the right format writer. * @param output The output the writer will have to fill. * @return A writer fitting our needs, or null if we couldn't find it. * @throws IOException if an error occured while checking writer capabilities. */ private ImageWriter getWriter(final String extension, Object output) throws IOException { // Check the tiff writers to know if they fit our needs : writing piece per piece. final Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName(extension); ImageWriter writer = null; while (writers.hasNext()) { writer = writers.next(); try { writer.setOutput(output); } catch (IllegalArgumentException e) { continue; } if (writer.canWriteEmpty()) { break; } } return writer; } /** * A utility thread class to resample a piece of image. */ private class ResampleThread implements Runnable { /** * The math transform which contains main transformation for resampling. It will be concatenated with additional * transformation which is the offset for the image to fill. */ final MathTransform baseTransform; final Interpolation interpolator; final double[] fillValue; final ColorModel outCModel; public ResampleThread(MathTransform operator, Interpolation source, double[] defaultValue, ColorModel outputModel) { ArgumentChecks.ensureNonNull("Math transform", operator); ArgumentChecks.ensureNonNull("interpolator", source); ArgumentChecks.ensureNonNull("Fill value", defaultValue); ArgumentChecks.ensureNonNull("Output color model", outputModel); baseTransform = operator; interpolator = source; fillValue = defaultValue; outCModel = outputModel; } @Override public void run() { Rectangle computeZone; BufferedImage destination; MathTransform gridTransform; try { while (!Thread.currentThread().isInterrupted()) { computeZone = resamplingQueue.take(); if (computeZone instanceof EmptyBox) { LOGGER.log(Level.INFO, "Resampling thread acquired end of the queue."); return; } gridTransform = MathTransforms.concatenate( new AffineTransform2D(1d, 0, 0, 1d, (double) computeZone.x, (double) computeZone.y), baseTransform); destination = new BufferedImage(outCModel, outCModel.createCompatibleWritableRaster(computeZone.width, computeZone.height), false, null); final Resample resampler = new Resample(gridTransform, destination, interpolator, fillValue); resampler.fillImagePx(); final Map.Entry<Point, RenderedImage> output = new AbstractMap.SimpleEntry<>(computeZone.getLocation(), (RenderedImage) destination); writingQueue.offer(output, TIMEOUT, TIMEOUT_UNIT); } LOGGER.log(Level.INFO, "Owner thread of the resample has been interrupted."); } catch (InterruptedException e) { LOGGER.log(Level.INFO, "Resampling worker interrupted !"); Thread.currentThread().interrupt(); } catch (Exception e) { LOGGER.log(Level.WARNING, "Resampling thread error !", e); // We die, but not alone poisonResamplingQueue(); poisonWritingQueue(); throw new RuntimeException(e); } } } /** * A thread for image writing passes. The aim is to have a single runnable which will wait for strips to write. When * a new strip is available, the thread will put himself into the queue which was given to him as available. */ private class Writer implements Runnable { private final ImageWriter writer; final ImageWriteParam writeParam; public Writer(final ImageWriter writer) throws IOException { ArgumentChecks.ensureNonNull("Image writer", writer); if (!writer.canReplacePixels(0)) { throw new IllegalArgumentException("Input image writer is not able to write images piece by piece."); } this.writer = writer; writeParam = writer.getDefaultWriteParam(); } @Override public void run() { Map.Entry<Point, RenderedImage> toWrite; try { while (!Thread.currentThread().isInterrupted()) { toWrite = writingQueue.take(); if (toWrite instanceof EndOfFile) { LOGGER.log(Level.INFO, "Writing thread acquired end of the queue."); return; } writeParam.setDestinationOffset(toWrite.getKey()); writer.replacePixels(toWrite.getValue(), writeParam); } } catch (InterruptedException e) { LOGGER.log(Level.INFO, "Writing thread interrupted !"); // We die, but not alone poisonResamplingQueue(); Thread.currentThread().interrupt(); } catch (Exception e) { LOGGER.log(Level.WARNING, "Writer thread error !", e); poisonResamplingQueue(); throw new RuntimeException(e); } } } /** A poisonous object to tell our resamplers there is no more data. */ private static class EmptyBox extends Rectangle {} /** A poisonous object to tell writer he can shutdown. */ private static class EndOfFile<A, B> implements Map.Entry<A, B> { @Override public A getKey() { throw new RuntimeException("Poisonous object !"); } @Override public B getValue() { throw new RuntimeException("Poisonous object !"); } @Override public B setValue(B value) { throw new RuntimeException("Poisonous object !"); } } }