/* * GeoTools - The Open Source Java GIS Toolkit * http://geotools.org * * (C) 2015, Open Source Geospatial Foundation (OSGeo) * (C) 2014 TOPP - www.openplans.org. * * 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.geotools.coverage.processing.operation; import it.geosolutions.jaiext.JAIExt; import it.geosolutions.jaiext.range.NoDataContainer; import it.geosolutions.jaiext.range.Range; import it.geosolutions.jaiext.range.RangeFactory; import java.awt.RenderingHints; import java.awt.geom.AffineTransform; import java.awt.geom.NoninvertibleTransformException; import java.awt.image.RenderedImage; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import javax.media.jai.ImageLayout; import javax.media.jai.JAI; import javax.media.jai.ParameterBlockJAI; import javax.media.jai.PlanarImage; import javax.media.jai.ROI; import org.geotools.coverage.GridSampleDimension; import org.geotools.coverage.grid.GridCoverage2D; import org.geotools.coverage.grid.GridEnvelope2D; import org.geotools.coverage.grid.GridGeometry2D; import org.geotools.coverage.processing.CoverageProcessingException; import org.geotools.coverage.processing.OperationJAI; import org.geotools.factory.Hints; import org.geotools.geometry.Envelope2D; import org.geotools.geometry.jts.JTS; import org.geotools.metadata.iso.citation.Citations; import org.geotools.parameter.DefaultParameterDescriptor; import org.geotools.parameter.ImagingParameterDescriptors; import org.geotools.parameter.ImagingParameters; import org.geotools.referencing.operation.transform.AffineTransform2D; import org.geotools.resources.coverage.CoverageUtilities; import org.geotools.resources.i18n.ErrorKeys; import org.geotools.resources.i18n.Errors; import org.geotools.resources.image.ImageUtilities; import org.geotools.util.Utilities; import org.geotools.util.logging.Logging; import it.geosolutions.jaiext.utilities.ImageLayout2; import it.geosolutions.jaiext.vectorbin.ROIGeometry; import org.opengis.coverage.Coverage; import org.opengis.geometry.MismatchedDimensionException; import org.opengis.metadata.spatial.PixelOrientation; import org.opengis.parameter.InvalidParameterValueException; import org.opengis.parameter.ParameterDescriptor; import org.opengis.parameter.ParameterNotFoundException; import org.opengis.parameter.ParameterValueGroup; import org.opengis.referencing.crs.CoordinateReferenceSystem; import org.opengis.referencing.datum.PixelInCell; import org.opengis.referencing.operation.MathTransform; import org.opengis.referencing.operation.MathTransform2D; import org.opengis.referencing.operation.TransformException; import org.opengis.util.InternationalString; import com.vividsolutions.jts.geom.Geometry; /** * {@link OperationJAI} subclass used for executing the "Merge" of multiple coverages into a single coverage with multiple bands. This operation can * be used also for merging coverages which are not aligned and with different resolutions. The user should only set: * * <ul> * <li>the Coverages (Note that they must be in the same CRS).</li> * <li>the optional Geometry to use as ROI.</li> * <li>The optional policy for choosing the Grid To World transformation(FIRST for that of the first coverage, LAST for the last one, INDEX for that * of a coverage defined by the "index" parameter).</li> * <li>the index parameter for choosing the main Coverage.</li> * </ul> * * @author Nicola Lagomarsini, GeoSolutions S.A.S. * */ public class BandMerge extends OperationJAI { /** Name for the TRANSFORM_CHOICE parameter */ public static final String TRANSFORM_CHOICE = "transform_choice"; /** Name for the COVERAGE_INDEX parameter */ public static final String COVERAGE_INDEX = "coverage_idx"; /** Name for the GEOMETRY parameter */ public static final String GEOMETRY = "geometry"; /** * The parameter descriptor for the Sources. */ public static final ParameterDescriptor SOURCES = new DefaultParameterDescriptor(Citations.JAI, "Sources", Collection.class, // Value class (mandatory) null, // Array of valid values null, // Default value null, // Minimal value null, // Maximal value null, // Unit of measure true); /** * The parameter descriptor for the Transformation Choice. */ public static final ParameterDescriptor TRANSFORM_CHOICE_PARAM = new DefaultParameterDescriptor( Citations.JAI, TRANSFORM_CHOICE, String.class, // Value class (mandatory) null, // Array of valid values null, // Default value null, // Minimal value null, // Maximal value null, // Unit of measure false); /** * The parameter descriptor for the Source index to use for selecting the Affine Transformation to use. */ public static final ParameterDescriptor INDEX = new DefaultParameterDescriptor(Citations.JAI, COVERAGE_INDEX, Integer.class, // Value class (mandatory) null, // Array of valid values 0, // Default value 0, // Minimal value null, // Maximal value null, // Unit of measure false); /** * The parameter descriptor for the Transformation Choice. */ public static final ParameterDescriptor GEOMETRY_PARAM = new DefaultParameterDescriptor( Citations.JAI, GEOMETRY, Geometry.class, // Value class (mandatory) null, // Array of valid values null, // Default value null, // Minimal value null, // Maximal value null, // Unit of measure false); private static final Logger LOGGER = Logging.getLogger(BandMerge.class); private static Set<ParameterDescriptor> REPLACED_DESCRIPTORS; // Replace the old parameter descriptor group with a new one with the old parameters and the new // ones defined above. static { final Set<ParameterDescriptor> replacedDescriptors = new HashSet<ParameterDescriptor>(); replacedDescriptors.add(SOURCES); replacedDescriptors.add(INDEX); replacedDescriptors.add(TRANSFORM_CHOICE_PARAM); replacedDescriptors.add(GEOMETRY_PARAM); REPLACED_DESCRIPTORS = Collections.unmodifiableSet(replacedDescriptors); } /** * Enum used for selecting an Affine Transformation to use for backward mapping the final coverage pixel to the Model Space. The method * "getTransformationList" returns a List of the AffineTransformations to use for backward mapping the destination pixels into each source * coverage pixel. * * @author Nicola Lagomarsini, GeoSolutions S.A.S. * */ public enum TransformList { FIRST("first") { @Override public AffineTransform getGridToCRS2D(List<GridGeometry2D> list, int index) { if (list.isEmpty()) { throw new IllegalArgumentException("No Affine Transformation found"); } return getAffineTransform(list.get(0), true); } @Override public AffineTransform getCRStoGrid2D(List<GridGeometry2D> list, int index) { if (list.isEmpty()) { throw new IllegalArgumentException("No Affine Transformation found"); } return getAffineTransform(list.get(0), false); } }, LAST("last") { @Override public AffineTransform getGridToCRS2D(List<GridGeometry2D> list, int index) { if (list.isEmpty()) { throw new IllegalArgumentException("No Affine Transformation found"); } return getAffineTransform(list.get(list.size() - 1), true); } @Override public AffineTransform getCRStoGrid2D(List<GridGeometry2D> list, int index) { if (list.isEmpty()) { throw new IllegalArgumentException("No Affine Transformation found"); } return getAffineTransform(list.get(list.size() - 1), false); } }, INDEX("index") { @Override public AffineTransform getGridToCRS2D(List<GridGeometry2D> list, int index) { if (index < 0) { // Take the first available return FIRST.getGridToCRS2D(list, index); } else if (index >= list.size()) { // Take the last available return LAST.getGridToCRS2D(list, index); } return getAffineTransform(list.get(index), true); } @Override public AffineTransform getCRStoGrid2D(List<GridGeometry2D> list, int index) { if (index < 0) { // Take the first available return FIRST.getCRStoGrid2D(list, index); } else if (index >= list.size()) { // Take the last available return LAST.getCRStoGrid2D(list, index); } return getAffineTransform(list.get(index), false); } }; /** * Returns a List of AffineTransformations objects to use for backward mapping the destination image pixels into each source image * * @param list * @param index * @return */ public List<AffineTransform> getTransformationList(List<GridGeometry2D> list, int index) { // Creation of a List of Transformations List<AffineTransform> transforms = new ArrayList<AffineTransform>(); // Get the g2w transform to use for the remapping AffineTransform g2w = getGridToCRS2D(list, index); // Get all the other w2g transforms to concatenate for the remapping for (GridGeometry2D gg2D : list) { AffineTransform tr = new AffineTransform(getAffineTransform(gg2D, false)); tr.concatenate(g2w); transforms.add(tr); } // Returns the transformations list return transforms; } /** Name associated to the {@link TransformList} object */ private String name; // private constructor, avoiding instantiation private TransformList(String name) { this.name = name; } /** * Returns the Grid To World transformation from the following GridGeometry list. The result depends on the implementation * * @param list * @param index * @return */ public abstract AffineTransform getGridToCRS2D(List<GridGeometry2D> list, int index); /** * Returns the World To Grid transformation from the following GridGeometry list. The result depends on the implementation * * @param list * @param index * @return */ public abstract AffineTransform getCRStoGrid2D(List<GridGeometry2D> list, int index); /** Static method for choosing the TransformList Object associated to the input string */ public static TransformList getTransformList(String choice) { if (choice.equalsIgnoreCase(LAST.name)) { return LAST; } else if (choice.equalsIgnoreCase(INDEX.name)) { return INDEX; } return FIRST; } /** * Static method for taking the AffineTransform from the List * * @param list * @param index * @return */ private static AffineTransform getAffineTransform(GridGeometry2D gg2D, boolean grid2crs) { MathTransform2D tr = null; if (grid2crs) { tr = gg2D.getGridToCRS2D(PixelOrientation.UPPER_LEFT); } else { tr = gg2D.getCRSToGrid2D(PixelOrientation.UPPER_LEFT); } if (tr instanceof AffineTransform2D) { return (AffineTransform2D) tr; } else { throw new IllegalArgumentException(tr.toString() + " is not an AffineTransform"); } } } public BandMerge() { super(getOperationDescriptor("BandMerge"), new ImagingParameterDescriptors( getOperationDescriptor("BandMerge"), REPLACED_DESCRIPTORS)); } @Override public Coverage doOperation(ParameterValueGroup parameters, Hints hints) throws CoverageProcessingException { /* * Extracts the source grid coverages now as a List. The sources will be set in the ParameterBlockJAI (as RenderedImages) later. */ final Collection<GridCoverage2D> sourceCollection = new ArrayList<GridCoverage2D>(); extractSources(parameters, sourceCollection); // Selection of the first coverage GridCoverage2D coverage = sourceCollection.iterator().next(); // CRS to use. The first CRS is used final CoordinateReferenceSystem crs = coverage.getCoordinateReferenceSystem2D(); // Global bounding Box to use Envelope2D globalBbox = new Envelope2D(); // Transformation choice string parameter String transChoice = (String) parameters.parameter(TRANSFORM_CHOICE).getValue(); // The TransformList object is initilaized to FIRST in order to take the first element. TransformList choice = TransformList.FIRST; // Setting of the correct TransformList object to use if the parameter is present if (transChoice != null && !transChoice.isEmpty()) { choice = TransformList.getTransformList(transChoice); } // Source number int size = sourceCollection.size(); // GridGeometry List for all the sources List<GridGeometry2D> gg2D = new ArrayList<GridGeometry2D>(size); // Loop through the for (GridCoverage2D source : sourceCollection) { if (source == null) { size--; continue; } // Expand the BBOX for containing all the input Coverages globalBbox.include(source.getEnvelope2D()); // Add the gridGeometry associated to the coverage gg2D.add(source.getGridGeometry()); } // Creation of the list of the transformations to use. List<AffineTransform> tr = choice.getTransformationList(gg2D, getIndex(parameters)); // Selection of the GridToWorld transformation to use for the final coverage AffineTransform2D gridToCRS = new AffineTransform2D(choice.getGridToCRS2D(gg2D, getIndex(parameters))); // Selection of the WorldToGrid transformation to use for the final coverage AffineTransform2D crsToGrid = new AffineTransform2D(choice.getCRStoGrid2D(gg2D, getIndex(parameters))); // Storing the input sources into and array GridCoverage2D[] sources = new GridCoverage2D[size]; sourceCollection.toArray(sources); // Creation of the ParameterBlockJAI object to pass to JAI. ParameterBlockJAI block; try { block = prepareParameters(parameters, sources, tr, crsToGrid); } catch (MismatchedDimensionException e) { throw new CoverageProcessingException(e); } catch (ParameterNotFoundException e) { throw new CoverageProcessingException(e); } catch (TransformException e) { throw new CoverageProcessingException(e); } /* * Applies the operation. */ return deriveGridCoverage(sources, new BandMergeParams(crs, gridToCRS, globalBbox, block, hints)); } /** * Method for searching the index of the coverage associated to the Main g2w transformation If nothing is found the first coverage is taken(index * = 0) * * @param parameters * @return */ private int getIndex(ParameterValueGroup parameters) { // Get the index parameter from the parameter group Object idx = parameters.parameter(COVERAGE_INDEX).getValue(); if (idx != null && idx instanceof Integer) { return ((Integer) idx).intValue(); } // If nothing is found, then the index for the first coverage is returned return 0; } /** * Extraction of the sources from the parameter called SOURCES. The sources are stored inside a List. * * @param parameters * @param sources * @return * @throws ParameterNotFoundException * @throws InvalidParameterValueException */ protected void extractSources(final ParameterValueGroup parameters, final Collection<GridCoverage2D> sources) throws ParameterNotFoundException, InvalidParameterValueException { Utilities.ensureNonNull("parameters", parameters); Utilities.ensureNonNull("sources", sources); // Extraction of the sources from the parameters Object srcCoverages = parameters.parameter("sources").getValue(); if (!(srcCoverages instanceof Collection) || ((Collection) srcCoverages).isEmpty() || !(((Collection) srcCoverages).iterator().next() instanceof GridCoverage2D)) { throw new InvalidParameterValueException(Errors.format(ErrorKeys.ILLEGAL_ARGUMENT_$1, "sources"), "sources", srcCoverages); } // Collection of the sources to use Collection<GridCoverage2D> sourceCoverages = (Collection<GridCoverage2D>) srcCoverages; // Cycle on all the Sources for (GridCoverage2D source : sourceCoverages) { // Store the i-th source sources.add(source); } } /** * Applies the BandMerge operation to a grid coverage. The following steps are performed: * * <ul> * <li>Gets the {@linkplain GridSampleDimension sample dimensions} for the target images by invoking the {@link #deriveSampleDimension * deriveSampleDimension(...)} method.</li> * <li>Applied the JAI operation using {@link #createRenderedImage}.</li> * <li>Wraps the result in a {@link GridCoverage2D} object.</li> * </ul> * * @param sources The source coverages. * @param parameters Parameters, rendering hints and coordinate reference system to use. * @return The result as a grid coverage. * * @see #doOperation * @see JAI#createNS */ private GridCoverage2D deriveGridCoverage(final GridCoverage2D[] sources, final BandMergeParams parameters) { GridCoverage2D primarySource = sources[PRIMARY_SOURCE_INDEX]; /* * Gets the target SampleDimensions. If they are identical to the SampleDimensions of one of the source GridCoverage2D, then this * GridCoverage2D will be used at the primary source. It will affect the target GridCoverage2D's name and the visible band. Then, a new color * model will be constructed from the new SampleDimensions, taking in account the visible band. */ final GridSampleDimension[][] list = new GridSampleDimension[sources.length][]; for (int i = 0; i < list.length; i++) { if (sources[i] == null) { continue; } list[i] = sources[i].getSampleDimensions(); } // Newly ordered GridSampleDimensions final GridSampleDimension[] sampleDims = deriveSampleDimension(list, null); int primarySourceIndex = PRIMARY_SOURCE_INDEX; /* * Set the rendering hints image layout. Only the following properties will be set: * * - Color model */ RenderingHints hints = ImageUtilities.getRenderingHints(parameters.getSource()); ImageLayout layout = (hints != null) ? (ImageLayout) hints.get(JAI.KEY_IMAGE_LAYOUT) : null; // Selection of the Bounding Box to use if present Envelope2D bbox = parameters.bbox; if (layout != null) { // If BBOX is present the it is added to the layout if (bbox != null) { updateLayout(parameters, layout, bbox); } if (hints == null) { hints = new RenderingHints(JAI.KEY_IMAGE_LAYOUT, layout); } else { hints.put(JAI.KEY_IMAGE_LAYOUT, layout); } } else if (bbox != null) { // New Layout Creation layout = new ImageLayout2(); // Setting of the layout associated to the Final BoundingBox updateLayout(parameters, layout, bbox); if (hints == null) { hints = new RenderingHints(JAI.KEY_IMAGE_LAYOUT, layout); } else { hints.put(JAI.KEY_IMAGE_LAYOUT, layout); } } // Setting of the Hints to use if (parameters.hints != null) { if (hints != null) { hints.add(parameters.hints); // May overwrite the image layout we have just set. } else { hints = parameters.hints; } } /* * Performs the operation using JAI and construct the new grid coverage. Uses the coordinate system from the main source coverage in order to * preserve the extra dimensions (if any). The first two dimensions should be equal to the coordinate system set in the 'parameters' block. */ final InternationalString name = deriveName(sources, primarySourceIndex, null); final CoordinateReferenceSystem crs = primarySource.getCoordinateReferenceSystem(); final MathTransform toCRS = parameters.gridToCRS; final RenderedImage data = createRenderedImage(parameters.parameters, hints); final Map properties = getProperties(data, crs, name, toCRS, sources, parameters); // The gridToCRS refers to the corner. Make sure to create a GridGeometry accordingly GridGeometry2D gridGeometry2D = new GridGeometry2D( new GridEnvelope2D(PlanarImage.wrapRenderedImage(data).getBounds()), PixelInCell.CELL_CORNER, toCRS, crs, parameters.hints); return getFactory(parameters.hints).create(name, // The grid coverage name data, // The underlying data gridGeometry2D, sampleDims, // The sample dimensions sources, // The source grid coverages. properties); // Properties } /** * This method is used for setting the final image layout. * * @param parameters * @param layout * @param bbox */ private void updateLayout(final BandMergeParams parameters, ImageLayout layout, Envelope2D bbox) { // Creation of a GridGeoemtry with the selected BBOX and the defined World2Grid transformation GridGeometry2D gg2D = new GridGeometry2D(PixelInCell.CELL_CORNER, parameters.gridToCRS, bbox, null); // Selection of the GridEnvelope from the GridGeometry for using it as the new Layout GridEnvelope2D gridRange2D = gg2D.getGridRange2D(); layout.setMinX(gridRange2D.x); layout.setMinY(gridRange2D.y); layout.setWidth(gridRange2D.width); layout.setHeight(gridRange2D.height); } protected Map getProperties(RenderedImage data, CoordinateReferenceSystem crs, InternationalString name, MathTransform toCRS, GridCoverage2D[] sources, BandMergeParams parameters) { // Merge the coverage properties Map properties = new HashMap(); for (GridCoverage2D cov : sources) { if (cov != null && cov.getProperties() != null) { properties.putAll(cov.getProperties()); } } // Setting ROI and NoData if present if (JAIExt.isJAIExtOperation("BandMerge")) { ParameterBlockJAI pb = parameters.parameters; CoverageUtilities.setROIProperty(properties, (ROI) pb.getObjectParameter(3)); CoverageUtilities.setNoDataProperty(properties, pb.getObjectParameter(1)); } return properties; } protected GridSampleDimension[] deriveSampleDimension(GridSampleDimension[][] list, Parameters parameters) { // Total number of sample dimensions int numDim = 0; // Cycle on the input list in order to calculate the number of sample dimensions for (GridSampleDimension[] array : list) { numDim += array.length; } if (numDim == 0) { return null; } // Creation of a new GridSampleDimension array List<GridSampleDimension> sampleDims = new ArrayList<GridSampleDimension>(numDim); for (GridSampleDimension[] array : list) { for (GridSampleDimension sample : array) { sampleDims.add(sample); } } // Array containing the final Coverage SampleDimensions GridSampleDimension[] dims = new GridSampleDimension[numDim]; return sampleDims.toArray(dims); } /** * This method prepares the {@link ParameterBlockJAI} to pass to JAI in order to execute the {@link BandMerge} operation. * * @param parameters * @param sources * @param tr * @param crsToGRID * @return * @throws MismatchedDimensionException * @throws ParameterNotFoundException * @throws TransformException */ private ParameterBlockJAI prepareParameters(final ParameterValueGroup parameters, GridCoverage2D[] sources, List<AffineTransform> tr, AffineTransform2D crsToGRID) throws MismatchedDimensionException, ParameterNotFoundException, TransformException { final ImagingParameters copy = (ImagingParameters) descriptor.createValue(); final ParameterBlockJAI block = (ParameterBlockJAI) copy.parameters; Range[] nodata = new Range[sources.length]; // Image dataType int dataType = sources[0].getRenderedImage().getSampleModel().getDataType(); // No Data check for (int i = 0; i < sources.length; i++) { GridCoverage2D cov = sources[i]; // Setting of the Source block.setSource(cov.getRenderedImage(), i); // Check on the image datatype int dataTypeCov = cov.getRenderedImage().getSampleModel().getDataType(); if (dataType != dataTypeCov) { throw new IllegalArgumentException("Input Coverages must have the same data type"); } // Creation of the NoData range associated nodata[i] = createNoDataRange(cov, dataType); } if (JAIExt.isJAIExtOperation("BandMerge")) { // Setting NoData block.setParameter("noData", nodata); // Setting Transformations block.setParameter("transformations", tr); // Setting ROI ROI roi = null; if (parameters.parameter(GEOMETRY).getValue() != null) { // Creation of a ROI geometry object from the Geometry roi = new ROIGeometry(JTS.transform((Geometry) parameters.parameter(GEOMETRY) .getValue(), crsToGRID)); } // Check if the coverages contains a ROI property for (int i = 0; i < sources.length; i++) { GridCoverage2D cov = sources[i]; ROI covROI = CoverageUtilities.getROIProperty(cov); if (covROI != null) { ROI newROI = null; // Check if it must be transformed if (tr != null) { try { AffineTransform trans = tr.get(i).createInverse(); newROI = covROI.transform(trans); } catch (NoninvertibleTransformException e) { LOGGER.log(Level.SEVERE, e.getMessage(), e); } } else { newROI = covROI; } if (roi == null) { roi = newROI; } else { roi = roi.intersect(newROI); } } } // Addition of the ROI to the ParameterBlock if (roi != null) { block.setParameter("roi", roi); } // Setting the destination No Data Value as the NoData of the principal coverage selected block.setParameter("destinationNoData", nodata[getIndex(parameters)].getMin() .doubleValue()); } return block; } /** * Method for creating the nodata range associated to each coverage * * @param cov * @param dataType * @return */ private Range createNoDataRange(GridCoverage2D cov, int dataType) { // Extract NoData property from gridCoverage NoDataContainer container = CoverageUtilities.getNoDataProperty(cov); if(container != null){ return container.getAsRange(); } // No property set, use the input NoData Range double[] nodatas = CoverageUtilities.getBackgroundValues(cov); if(nodatas != null && nodatas.length > 0){ Range noData = RangeFactory .convert(RangeFactory.create(nodatas[0], nodatas[0]), dataType); return noData; } return null; } /** * Container class used for passing various parameters to the deriveGridCoverage method. The structure is similar to that of the * {@link Parameters} class. * * @author Nicola Lagomarsini, GeoSolutions S.A.S. * */ static class BandMergeParams { /** * The two dimensional coordinate reference system for all sources and the destination {@link GridCoverage2D}. */ public final CoordinateReferenceSystem crs; /** * The "grid to coordinate reference system" transform chosen for all the Coverages. */ public final AffineTransform2D gridToCRS; /** * The parameters to be given to the {@link JAI#createNS} method. */ public final ParameterBlockJAI parameters; /** * The rendering hints to be given to the {@link JAI#createNS} method. The {@link JAI} instance to use for the {@code createNS} call will be * fetch from the {@link Hints#JAI_INSTANCE} key. */ public final Hints hints; /** * The Bounding box of the Final Coverage */ public Envelope2D bbox; /** * Constructs a new instance with the specified values. */ BandMergeParams(final CoordinateReferenceSystem crs, final AffineTransform2D gridToCRS, final Envelope2D bbox, final ParameterBlockJAI parameters, final Hints hints) { this.crs = crs; this.gridToCRS = gridToCRS; this.bbox = bbox; this.parameters = parameters; this.hints = hints; } /** * Returns the first source image, or {@code null} if none. */ final RenderedImage getSource() { final int n = parameters.getNumSources(); for (int i = 0; i < n; i++) { final Object source = parameters.getSource(i); if (source instanceof RenderedImage) { return (RenderedImage) source; } } return null; } } }