You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by am...@apache.org on 2022/12/01 17:33:32 UTC
[sis] 02/02: feat(Feature): add a computed image for band aggregation
This is an automated email from the ASF dual-hosted git repository.
amanin pushed a commit to branch feat/resource-processor
in repository https://gitbox.apache.org/repos/asf/sis.git
commit a0a748dea2ca79335f6967f9e40bcf52deca5619
Author: Alexis Manin <al...@geomatys.com>
AuthorDate: Mon Nov 28 17:59:18 2022 +0100
feat(Feature): add a computed image for band aggregation
First prototype with tests on aligned tiles.
Miss color model.
Lack some optimizations over banded images.
---
.../org/apache/sis/image/BandAggregateImage.java | 394 +++++++++++++++++++++
.../java/org/apache/sis/image/ImageProcessor.java | 31 ++
.../apache/sis/image/BandAggregateImageTest.java | 226 ++++++++++++
.../apache/sis/test/suite/FeatureTestSuite.java | 1 +
4 files changed, 652 insertions(+)
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/BandAggregateImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/BandAggregateImage.java
new file mode 100644
index 0000000000..0177d90563
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/BandAggregateImage.java
@@ -0,0 +1,394 @@
+package org.apache.sis.image;
+
+import java.awt.Dimension;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.geom.Point2D;
+import java.awt.image.BandedSampleModel;
+import java.awt.image.ColorModel;
+import java.awt.image.Raster;
+import java.awt.image.RenderedImage;
+import java.awt.image.SampleModel;
+import java.awt.image.WritableRaster;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.IntStream;
+import org.apache.sis.util.ArgumentChecks;
+
+import static java.lang.Math.multiplyExact;
+
+final class BandAggregateImage extends ComputedImage {
+
+ private final Specification spec;
+
+ private BandAggregateImage(Specification spec) {
+ super(spec.tileModel, spec.sources.stream().map(TileSource::getSource).toArray(RenderedImage[]::new));
+ this.spec = spec;
+ }
+
+ @Override
+ protected Raster computeTile(int tileX, int tileY, WritableRaster previous) throws Exception {
+ final Point tileLocation = tileToPixel(tileX, tileY);
+ return spec.strategy.aggregate(tileX, tileY, tileLocation, spec);
+ }
+
+ @Override
+ public ColorModel getColorModel() { return spec.aggregationColors; }
+
+ @Override
+ public int getWidth() {
+ return spec.pixelRegion.width;
+ }
+
+ @Override
+ public int getHeight() {
+ return spec.pixelRegion.height;
+ }
+
+ @Override
+ public int getMinX() { return spec.pixelRegion.x; }
+
+ @Override
+ public int getMinY() { return spec.pixelRegion.y; }
+
+ @Override
+ public int getMinTileX() { return spec.tileDisposition.x; }
+
+ @Override
+ public int getMinTileY() { return spec.tileDisposition.y; }
+
+ @Override
+ public int getNumXTiles() { return spec.tileDisposition.width; }
+
+ @Override
+ public int getNumYTiles() { return spec.tileDisposition.height; }
+
+ private Point tileToPixel(int tileX, int tileY) {
+ int pX = this.getMinX() + multiplyExact((tileX - getMinTileX()), getTileWidth());
+ int pY = this.getMinY() + multiplyExact((tileY - getMinTileY()), getTileHeight());
+ return new Point(pX, pY);
+ }
+
+ /*
+ * FACTORY METHODS
+ */
+
+ static RenderedImage aggregateBands(RenderedImage[] sources, int[][] bandsToPreserve) {
+ final ContextInformation info = parseAndValidateInput(sources, bandsToPreserve);
+ return tryTileOptimizedStrategy(info)
+ .rightOr(reason
+ -> fallbackStrategy(info)
+ .mapLeft(otherReason -> "Reasons: " + reason + ", " + otherReason)
+ )
+ .mapRight(BandAggregateImage::new)
+ .getRightOrThrow(IllegalArgumentException::new);
+ }
+
+ /*
+ * PRIVATE STATIC METHODS
+ */
+
+ /**
+ * Initial analysis of input images to aggregate. Note that this method aims to make source information more
+ * accessible and easy to use before further processing. It also try to detect incompatibilities early, to
+ * raise meaningful errors for users.
+ *
+ * Note: crunching data into a more dense/accessible shape aims to ease further analysis/optimisations. This should
+ * allow more lisible and less coupled code, to ease setup of strategies, readability and maintenance.
+ *
+ * @param sources images to aggregate, in order.
+ * @param bandsToPreserve Bands to use for each image, in order. Holds same contract as the {@link #aggregateBands(RenderedImage[], int[][]) factory method}.
+ * @return Parsed information about data sources.
+ * @throws IllegalArgumentException If we detect an incompatibility in source images that make them impossible to merge.
+ */
+ private static ContextInformation parseAndValidateInput(RenderedImage[] sources, int[][] bandsToPreserve) throws IllegalArgumentException {
+ if (bandsToPreserve != null && sources.length > bandsToPreserve.length) throw new IllegalArgumentException("More band selections than source images are provided.");
+ if (sources.length < 2) throw new IllegalArgumentException("At least two images are required for band aggregation. For band selection on a single image, please use dedicated utility");
+
+ List<Rectangle> domains = new ArrayList<>(sources.length);
+ List<SourceSelection> sourcesWithBands = new ArrayList<>(sources.length);
+ int commonDataType, minTileWidth, minTileWidthIdx, minTileHeight, minTileHeightIdx;
+ RenderedImage source = sources[0];
+ int[] bands = bandsToPreserve == null || bandsToPreserve.length < 1 ? null : bandsToPreserve[0];
+ SampleModel sourceSM = source.getSampleModel();
+ commonDataType = sourceSM.getDataType();
+ int numBands = validateAndCountBands(bands, sourceSM);
+ sourcesWithBands.add(new SourceSelection(source, bands));
+ minTileWidthIdx = minTileHeightIdx = 0;
+ minTileWidth = source.getTileWidth();
+ minTileHeight = source.getTileHeight();
+ domains.add(new Rectangle(source.getMinX(), source.getMinY(), source.getWidth(), source.getHeight()));
+ for (int i = 1 ; i < sources.length ; i++) {
+ source = sources[i];
+ sourceSM = source.getSampleModel();
+ int dataType = sourceSM.getDataType();
+ if (dataType != commonDataType) throw new IllegalArgumentException("Images to merge define different data types. This is not supported. Please align all images on a single data-type beforehand.");
+
+ bands = bandsToPreserve == null || bandsToPreserve.length <= i ? null : bandsToPreserve[i];
+ numBands += validateAndCountBands(bands, sourceSM);
+
+ sourcesWithBands.add(new SourceSelection(source, bands));
+
+ domains.add(new Rectangle(source.getMinX(), source.getMinY(), source.getWidth(), source.getHeight()));
+
+ if (minTileWidth > source.getTileWidth()) {
+ minTileWidth = source.getTileWidth();
+ minTileWidthIdx = i;
+ }
+ if (minTileHeight > source.getTileHeight()) {
+ minTileHeight = source.getTileHeight();
+ minTileHeightIdx = i;
+ }
+ }
+
+ // TODO: with current information, we should be able to adapt domain definition to a user configuration : intersection, union, strict.
+ // User could specify if he wants band aggregation image to cover the intersection or union of input images.
+ // "strict" mode would serve to prevent band aggregation on images using different domains (raise an error if domains are not the same).
+ // For now, we will use intersection.
+ final Rectangle intersection = domains.stream()
+ .reduce(Rectangle::intersection)
+ .filter(it -> !it.isEmpty())
+ .orElseThrow(() -> new IllegalArgumentException("source images do not intersect."));
+
+ return new ContextInformation(commonDataType, numBands, minTileWidthIdx, minTileHeightIdx, domains, intersection, sourcesWithBands);
+ }
+
+ private static int validateAndCountBands(int[] bandSelection, SampleModel model) {
+ final int numBands = model.getNumBands();
+ if (bandSelection == null || bandSelection.length < 1) return numBands;
+ for (int band : bandSelection) ArgumentChecks.ensureValidIndex(numBands, band);
+ return bandSelection.length;
+ }
+
+ private static Either<String, Specification> tryTileOptimizedStrategy(ContextInformation context) {
+ final RenderedImage tilingX = context.sources.get(context.minTileWidthIndex).image;
+ final int tileWidth = tilingX.getTileWidth();
+ final int minTileX = tilingX.getMinTileX();
+
+ final RenderedImage tilingY = context.sources.get(context.minTileHeightIndex).image;
+ final int tileHeight = tilingY.getTileHeight();
+ final int minTileY = tilingY.getMinTileY();
+ for (final SourceSelection source : context.sources) {
+ final RenderedImage img = source.image;
+ if (img.getNumXTiles() > 1 && img.getTileWidth() % tileWidth != 0) {
+ return Either.left("no common integer multiplier between sources for tile width");
+ }
+ else if (img.getNumYTiles() > 1 && img.getTileHeight() % tileHeight != 0) {
+ return Either.left("no common integer multiplier between sources for tile height");
+ }
+
+ if ((img.getMinX() - tilingX.getMinX()) % tileWidth != 0) {
+ return Either.left("Tiles are not aligned on X axis");
+ }
+
+ if ((img.getMinY() - tilingX.getMinY()) % tileHeight != 0) {
+ return Either.left("Tiles are not aligned on Y axis");
+ }
+ }
+
+ // Ensure domain covers entire tiles.
+ final Rectangle pixelDomain = context.intersection;
+ assert pixelDomain.width % tileWidth == 0 : "Computed pixel domain does not align with tile borders";
+ assert pixelDomain.height % tileHeight == 0 : "Computed pixel domain does not align with tile borders";
+
+ TileAlignedSource[] preparedSources = new TileAlignedSource[context.sources.size()];
+ for (int i = 0 ; i < preparedSources.length ; i++) {
+ final SourceSelection source = context.sources.get(i);
+ preparedSources[i] = new TileAlignedSource(source.image, source.bands);
+ }
+
+ final SampleModel tileModel = new BandedSampleModel(context.commonDataType, tileWidth, tileHeight, context.outputBandNumber);
+
+ Rectangle tileDisposition = new Rectangle(minTileX, minTileY, pixelDomain.width / tileWidth, pixelDomain.height / tileHeight);
+ return Either.right(new Specification(Collections.unmodifiableList(Arrays.asList(preparedSources)), createColorModel(context), tileModel, pixelDomain, tileDisposition, new TileCopy()));
+ }
+
+ private static ColorModel createColorModel(ContextInformation context) {
+ return null; // TODO
+ }
+
+ private static Either<String, Specification> fallbackStrategy(ContextInformation info) {
+ return Either.left("No fallback strategy yet");
+ }
+
+ /**
+ * INTERNAL TYPES
+ */
+
+ private static final class SourceSelection {
+ final RenderedImage image;
+ final int[] bands;
+
+ private SourceSelection(RenderedImage image, int[] bands) {
+ this.image = image;
+ this.bands = bands == null ? null : bands.clone();
+ }
+ }
+
+ private static class ContextInformation {
+ /**
+ * DataBuffer type used by all sources without exception. If sources used different datatypes, this is -1.
+ */
+ final int commonDataType;
+ /**
+ * Number of bands resulting from the image merge.
+ */
+ final int outputBandNumber;
+
+ /**
+ * Index of source image providing minimal tile <em>width</em>
+ */
+ final int minTileWidthIndex;
+
+ /**
+ * Index of source image providing minimal tile <em>height</em>
+ */
+ final int minTileHeightIndex;
+
+ /**
+ * Commodity attribute: provides source images <em>pixel</em> boundaries, in order.
+ */
+ final List<Rectangle> sourcePxDomains;
+
+ /**
+ * Intersection of all {@link #sourcePxDomains source pixel domains}.
+ */
+ private final Rectangle intersection;
+
+ final List<SourceSelection> sources;
+
+ public ContextInformation(int commonDataType, int outputBandNumber, int minTileWidthIndex, int minTileHeightIndex, List<Rectangle> sourcePxDomains, Rectangle intersection, List<SourceSelection> sources) {
+ this.commonDataType = commonDataType;
+ this.outputBandNumber = outputBandNumber;
+ this.minTileWidthIndex = minTileWidthIndex;
+ this.minTileHeightIndex = minTileHeightIndex;
+ this.sourcePxDomains = Collections.unmodifiableList(new ArrayList<>(sourcePxDomains));
+ this.intersection = intersection;
+ this.sources = Collections.unmodifiableList(new ArrayList<>(sources));
+ }
+ }
+
+ static class Specification {
+ final List<TileSource> sources;
+ final ColorModel aggregationColors;
+ final SampleModel tileModel;
+
+ final Rectangle pixelRegion;
+ final Rectangle tileDisposition;
+ final BandAggregationStrategy strategy;
+
+ public Specification(List<TileSource> sources, ColorModel aggregationColors, SampleModel tileModel, Rectangle pixelRegion, Rectangle tileDisposition, BandAggregationStrategy strategy) {
+ this.sources = sources;
+ this.aggregationColors = aggregationColors;
+ this.tileModel = tileModel;
+ this.pixelRegion = pixelRegion;
+ this.tileDisposition = tileDisposition;
+ this.strategy = strategy;
+ }
+ }
+
+ /**
+ * For now, only available tile source is "aligned", i.e. sources providing tiles covering computed image tiles.
+ * In the future, another implementation capable of providing Raster built from multiple crossing tiles could be
+ * done, to extend band aggregation capabilities.
+ */
+ interface TileSource {
+ RenderedImage getSource();
+
+ /**
+ * Return a raster aligned with <em>parent computed tile</em> described by input offset.
+ * @param computedTileX X coordinate of the <em>parent computed tile</em>.
+ * @param computedTileY Y coordinate of the <em>parent computed tile</em>.
+ * @return A raster whose domain match output computed tile.
+ */
+ Raster getTile(int computedTileX, int computedTileY, Rectangle computedTileRegion);
+ }
+
+ /**
+ * WARNING: only work with sources whose tile size is a multiple of computed image tiles, i.e: All and any tiles in
+ * computed images MUST be contained in one and only one source tile.
+ */
+ private static class TileAlignedSource implements TileSource {
+
+ private final RenderedImage source;
+
+ /**
+ * Null if all bands in the image are selected. Otherwise,ordered (not necessarily sorted) indices of selected bands.
+ */
+ private final int[] selectedBands;
+
+ public TileAlignedSource(RenderedImage source, int[] selectedBands) {
+ this.source = source;
+ this.selectedBands = selectedBands;
+ }
+
+ @Override
+ public RenderedImage getSource() {
+ return source;
+ }
+
+ @Override
+ public Raster getTile(int computedTileX, int computedTileY, Rectangle computedTileRegion) {
+ final double tX = source.getMinTileX() + (computedTileRegion.x - source.getMinX()) / (double) source.getTileWidth();
+ final double tY = source.getMinTileY() + (computedTileRegion.y - source.getMinY()) / (double) source.getTileHeight();
+ assert regionsAligned(computedTileRegion, new Point2D.Double(tX, tY))
+ : "Source tiles do not align with computed image tiles";
+ final Raster sourceTile = source.getTile((int) Math.floor(tX), (int) Math.floor(tY));
+ return sourceTile.createChild(computedTileRegion.x, computedTileRegion.y, computedTileRegion.width, computedTileRegion.height, computedTileRegion.x, computedTileRegion.y, selectedBands);
+ }
+
+ private boolean regionsAligned(Rectangle computedTileRegion, Point2D origin) {
+ final double sourceX = origin.getX() * source.getTileWidth() + source.getTileGridXOffset();
+ final double sourceY = origin.getY() * source.getTileHeight() + source.getTileGridYOffset();
+ return Math.abs(sourceX - computedTileRegion.x) < 1 && Math.abs(sourceY - computedTileRegion.y) < 1;
+ }
+ }
+
+ @FunctionalInterface
+ interface BandAggregationStrategy {
+ Raster aggregate(int tileX, int tileY, Point tilePixelOrigin, BandAggregateImage.Specification spec);
+ }
+
+ private static class TileCopy implements BandAggregationStrategy {
+
+ @Override
+ public Raster aggregate(int tileX, int tileY, Point tilePixelOrigin, BandAggregateImage.Specification spec) {
+ final WritableRaster output = WritableRaster.createWritableRaster(spec.tileModel, tilePixelOrigin);
+ int i = 0;
+ for (TileSource source : spec.sources) {
+ Raster sourceRaster = source.getTile(tileX, tileY, new Rectangle(tilePixelOrigin, new Dimension(spec.tileModel.getWidth(), spec.tileModel.getHeight())));
+ final int sourceNumBands = sourceRaster.getNumBands();
+ final int[] targetBands = IntStream.range(i, i + sourceNumBands).toArray();
+ WritableRaster outputBandsOfInterest = output.createWritableChild(output.getMinX(), output.getMinY(), output.getWidth(), output.getHeight(), output.getMinX(), output.getMinY(), targetBands);
+ outputBandsOfInterest.setRect(sourceRaster);
+ i += sourceNumBands;
+ }
+
+ assert i == spec.tileModel.getNumBands() : "Mismatch: unexpected number of transferred bands";
+
+ return output;
+ }
+ }
+
+ private static class Either<L, R> {
+
+ private final L left;
+ private final R right;
+
+ Either(L left, R right) {
+ if (left == null && right == null || left != null && right != null) throw new IllegalArgumentException("Expect exactly one non-null branch");
+ this.left = left;
+ this.right = right;
+ }
+
+ <V> Either<L, V> mapRight(Function<? super R, ? extends V> transformRightToValue) { return left == null ? right(transformRightToValue.apply(right)) : (Either<L, V>) this; }
+ <V> Either<V, R> mapLeft(Function<? super L, ? extends V> transformLeftToValue) { return right == null ? left(transformLeftToValue.apply(left)) : (Either<V, R>) this; }
+ public Either<L, R> rightOr(Function<? super L, Either<L, R>> recover) { return right == null ? recover.apply(left) : this; }
+ public <E extends Exception> R getRightOrThrow(Function<L, E> createError) throws E { if (right != null) return right; else throw createError.apply(left); }
+ static <L, R> Either<L, R> left(L leftValue) { return new Either<>(leftValue, null); }
+ static <L, R> Either<L, R> right(R rightValue) { return new Either<>(null, rightValue); }
+ }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
index 67396be6cc..27a3bbb6a0 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
@@ -1213,6 +1213,37 @@ public class ImageProcessor implements Cloneable {
}
}
+ /**
+ * Commodity method for {@link #aggregateBands(List, List)}. Calling it is equivalent to:
+ *
+ * {@code aggregateBands(Arrays.asList(sources), null);}
+ * @param sources images whose bands must be aggregated, in order. At least two images must be provided.
+ *
+ * @see #aggregateBands(List, List)
+ */
+ public RenderedImage aggregateBands(RenderedImage... sources) {
+ return aggregateBands(Arrays.asList(sources), null);
+ }
+
+ /**
+ *
+ * @param sources Rendered images to aggregate, in order.
+ * @param bandsToSelectPerSource Bands to select for each source image, in order.
+ * If null or empty, we assume that all bands of all images must be selected.
+ * Any null or empty item means that all bands of the respective source image must be preserved.
+ * @return A computed image whose bands are the bands of provided images, in order.
+ */
+
+ public RenderedImage aggregateBands(List<RenderedImage> sources, List<int[]> bandsToSelectPerSource) {
+ RenderedImage[] sourceArray = sources.toArray(new RenderedImage[sources.size()]);
+ int[][] bandSelection = bandsToSelectPerSource == null || bandsToSelectPerSource.isEmpty()
+ ? null
+ : bandsToSelectPerSource.stream()
+ .map(it -> it == null ? null : it.clone())
+ .toArray(int[][]::new);
+ return BandAggregateImage.aggregateBands(sourceArray, bandSelection);
+ }
+
/**
* Returns {@code true} if the given object is an image processor
* of the same class with the same configuration.
diff --git a/core/sis-feature/src/test/java/org/apache/sis/image/BandAggregateImageTest.java b/core/sis-feature/src/test/java/org/apache/sis/image/BandAggregateImageTest.java
new file mode 100644
index 0000000000..3807345951
--- /dev/null
+++ b/core/sis-feature/src/test/java/org/apache/sis/image/BandAggregateImageTest.java
@@ -0,0 +1,226 @@
+package org.apache.sis.image;
+
+import java.awt.Rectangle;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBuffer;
+import java.awt.image.Raster;
+import java.awt.image.RenderedImage;
+import java.awt.image.WritableRenderedImage;
+import java.util.Arrays;
+import java.util.function.IntBinaryOperator;
+import java.util.stream.IntStream;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+public class BandAggregateImageTest extends TestCase {
+
+ @Test
+ public void aggregateSingleBandImages() {
+ BufferedImage im1 = new BufferedImage(3, 3, BufferedImage.TYPE_BYTE_GRAY);
+ im1.getRaster().setSamples(0, 0, 3, 3, 0, IntStream.range(0, 3*3).map(it -> 1).toArray());
+ BufferedImage im2 = new BufferedImage(3, 3, BufferedImage.TYPE_BYTE_GRAY);
+ im2.getRaster().setSamples(0, 0, 3, 3, 0, IntStream.range(0, 3*3).map(it -> 2).toArray());
+
+ final RenderedImage result = processor().aggregateBands(im1, im2);
+ assertNotNull(result);
+
+ final Raster tile = result.getTile(0, 0);
+ assertEquals(2, tile.getNumBands());
+ assertEquals(new Rectangle(0, 0, 3, 3), tile.getBounds());
+ assertArrayEquals(
+ new int[] {
+ 1, 2, 1, 2, 1, 2,
+
+ 1, 2, 1, 2, 1, 2,
+
+ 1, 2, 1, 2, 1, 2
+ },
+ tile.getPixels(0, 0, 3, 3, (int[]) null)
+ );
+ }
+
+ @Test
+ public void aggregateSimilarTiledImages() {
+ aggregateSimilarTiledImages(true, true);
+ aggregateSimilarTiledImages(false, false);
+ aggregateSimilarTiledImages(true, false);
+ aggregateSimilarTiledImages(false, true);
+ }
+
+ @Test
+ public void aggregateImagesUsingSameExtentButDifferentTileSizes() {
+ // Note: we use different tile indices to test robustness. The aggregation algorithm should rely on pixel coordinates for absolute positioning and synchronisation of image domains.
+ final TiledImageMock tiled2x2 = new TiledImageMock(DataBuffer.TYPE_FLOAT, 1, 3, 1, 8, 4, 2, 2, 1, 2, true);
+ final TiledImageMock tiled4x1 = new TiledImageMock(DataBuffer.TYPE_FLOAT, 1, 3, 1, 8, 4, 4, 1, 3, 4, true);
+ final TiledImageMock oneTile = new TiledImageMock(DataBuffer.TYPE_FLOAT, 1, 3, 1, 8, 4, 8, 4, 5, 6, true);
+
+ init(tiled2x2, tiled4x1, oneTile);
+
+ final RenderedImage result = processor().aggregateBands(tiled2x2, tiled4x1, oneTile);
+ assertNotNull(result);
+ assertArrayEquals(new int[] { 3, 1, 8, 4, 2, 1, 1, 4 }, new int[] {
+ result.getMinX(), result.getMinY(), result.getWidth(), result.getHeight(), result.getTileWidth(), result.getTileHeight(), result.getMinTileX(), result.getMinTileY()
+ });
+
+ final Raster raster = result.getData();
+ assertEquals(new Rectangle(3, 1, 8, 4), raster.getBounds());
+ assertArrayEquals(
+ new int[] {
+ 1100, 2100, 3100, 1101, 2101, 3101, 1200, 2102, 3102, 1201, 2103, 3103, 1300, 2200, 3104, 1301, 2201, 3105, 1400, 2202, 3106, 1401, 2203, 3107,
+ 1110, 2300, 3110, 1111, 2301, 3111, 1210, 2302, 3112, 1211, 2303, 3113, 1310, 2400, 3114, 1311, 2401, 3115, 1410, 2402, 3116, 1411, 2403, 3117,
+ 1500, 2500, 3120, 1501, 2501, 3121, 1600, 2502, 3122, 1601, 2503, 3123, 1700, 2600, 3124, 1701, 2601, 3125, 1800, 2602, 3126, 1801, 2603, 3127,
+ 1510, 2700, 3130, 1511, 2701, 3131, 1610, 2702, 3132, 1611, 2703, 3133, 1710, 2800, 3134, 1711, 2801, 3135, 1810, 2802, 3136, 1811, 2803, 3137,
+ },
+ raster.getPixels(3, 1, 8, 4, (int[]) null)
+ );
+ }
+
+ @Test
+ public void aggregateImagesUsingDifferentExtentsAndDifferentSquaredTiling() {
+ // Tip: band number match image tile width. i.e:
+ // untiled image -> band 1
+ // tiled 2x2 -> bands 2 and 3
+ // tiled 4x4 -> bands 4 and 5
+ // tiled 6x6 -> band 6
+ final TiledImageMock untiled = new TiledImageMock(DataBuffer.TYPE_FLOAT, 1, 0, 0, 16, 13, 16, 13, 0, 0, true);
+ final TiledImageMock tiled2x2 = new TiledImageMock(DataBuffer.TYPE_FLOAT, 2, 4, 2, 8, 10, 2, 2, 0, 0, true);
+ final TiledImageMock tiled4x4 = new TiledImageMock(DataBuffer.TYPE_FLOAT, 2, 4, 2, 8, 8, 4, 4, 0, 0, true);
+ final TiledImageMock tiled6x6 = new TiledImageMock(DataBuffer.TYPE_FLOAT, 1, 2, 0, 12, 6, 6, 6, 0, 0, true);
+
+ init(untiled, tiled2x2, tiled4x4, tiled6x6);
+
+ final RenderedImage result = processor().aggregateBands(untiled, tiled2x2, tiled4x4, tiled6x6);
+ assertNotNull(result);
+ assertArrayEquals(new int[] { 4, 2, 8, 4, 2, 2, 0, 0 }, new int[] {
+ result.getMinX(), result.getMinY(), result.getWidth(), result.getHeight(), result.getTileWidth(), result.getTileHeight(), result.getMinTileX(), result.getMinTileY()
+ });
+
+ final Raster raster = result.getData();
+ assertEquals(new Rectangle(4, 2, 8, 4), raster.getBounds());
+ assertArrayEquals(
+ new int[] {
+ 1124, 2100, 3100, 4100, 5100, 6122, 1125, 2101, 3101, 4101, 5101, 6123, 1126, 2200, 3200, 4102, 5102, 6124, 1127, 2201, 3201, 4103, 5103, 6125, 1128, 2300, 3300, 4200, 5200, 6220, 1129, 2301, 3301, 4201, 5201, 6221, 1130, 2400, 3400, 4202, 5202, 6222, 1131, 2401, 3401, 4203, 5203, 6223,
+ 1134, 2110, 3110, 4110, 5110, 6132, 1135, 2111, 3111, 4111, 5111, 6133, 1136, 2210, 3210, 4112, 5112, 6134, 1137, 2211, 3211, 4113, 5113, 6135, 1138, 2310, 3310, 4210, 5210, 6230, 1139, 2311, 3311, 4211, 5211, 6231, 1140, 2410, 3410, 4212, 5212, 6232, 1141, 2411, 3411, 4213, 5213, 6233,
+
+ 1144, 2500, 3500, 4120, 5120, 6142, 1145, 2501, 3501, 4121, 5121, 6143, 1146, 2600, 3600, 4122, 5122, 6144, 1147, 2601, 3601, 4123, 5123, 6145, 1148, 2700, 3700, 4220, 5220, 6240, 1149, 2701, 3701, 4221, 5221, 6241, 1150, 2800, 3800, 4222, 5222, 6242, 1151, 2801, 3801, 4223, 5223, 6243,
+ 1154, 2510, 3510, 4130, 5130, 6152, 1155, 2511, 3511, 4131, 5131, 6153, 1156, 2610, 3610, 4132, 5132, 6154, 1157, 2611, 3611, 4133, 5133, 6155, 1158, 2710, 3710, 4230, 5230, 6250, 1159, 2711, 3711, 4231, 5231, 6251, 1160, 2810, 3810, 4232, 5232, 6252, 1161, 2811, 3811, 4233, 5233, 6253,
+ },
+ raster.getPixels(4, 2, 8, 4, (int[]) null)
+ );
+ }
+
+ /*
+
+ @Test
+ public void validateColorModel() {
+ throw new UnsupportedOperationException("TODO");
+ }
+
+ */
+ private void aggregateSimilarTiledImages(Boolean firstBanded, Boolean secondBanded) {
+ final TiledImageMock im1 = new TiledImageMock(DataBuffer.TYPE_INT, 2, 7, 7, 6, 9, 3, 3, 1, 2, firstBanded);
+ final TiledImageMock im2 = new TiledImageMock(DataBuffer.TYPE_INT, 2, 7, 7, 6, 9, 3, 3, 3, 4, secondBanded);
+
+ init(im1, im2);
+
+ final ImageProcessor processor = processor();
+ RenderedImage result = processor.aggregateBands(im1, im2);
+ assertNotNull(result);
+ assertArrayEquals(new int[] { 7, 7, 6, 9, 3, 3, 1, 2 }, new int[] { result.getMinX(), result.getMinY(), result.getWidth(), result.getHeight(), result.getTileWidth(), result.getTileHeight(), result.getMinTileX(), result.getMinTileY()});
+ Raster raster = result.getData();
+ assertEquals(4, raster.getNumBands());
+ assertEquals(new Rectangle(7, 7, 6, 9), raster.getBounds());
+
+ assertArrayEquals(
+ new int[] {
+ // Tile 1 Tile 2
+ 1100, 2100, 3100, 4100, 1101, 2101, 3101, 4101, 1102, 2102, 3102, 4102, 1200, 2200, 3200, 4200, 1201, 2201, 3201, 4201, 1202, 2202, 3202, 4202,
+ 1110, 2110, 3110, 4110, 1111, 2111, 3111, 4111, 1112, 2112, 3112, 4112, 1210, 2210, 3210, 4210, 1211, 2211, 3211, 4211, 1212, 2212, 3212, 4212,
+ 1120, 2120, 3120, 4120, 1121, 2121, 3121, 4121, 1122, 2122, 3122, 4122, 1220, 2220, 3220, 4220, 1221, 2221, 3221, 4221, 1222, 2222, 3222, 4222,
+ // Tile 3 Tile 4
+ 1300, 2300, 3300, 4300, 1301, 2301, 3301, 4301, 1302, 2302, 3302, 4302, 1400, 2400, 3400, 4400, 1401, 2401, 3401, 4401, 1402, 2402, 3402, 4402,
+ 1310, 2310, 3310, 4310, 1311, 2311, 3311, 4311, 1312, 2312, 3312, 4312, 1410, 2410, 3410, 4410, 1411, 2411, 3411, 4411, 1412, 2412, 3412, 4412,
+ 1320, 2320, 3320, 4320, 1321, 2321, 3321, 4321, 1322, 2322, 3322, 4322, 1420, 2420, 3420, 4420, 1421, 2421, 3421, 4421, 1422, 2422, 3422, 4422,
+ // Tile 5 Tile 6
+ 1500, 2500, 3500, 4500, 1501, 2501, 3501, 4501, 1502, 2502, 3502, 4502, 1600, 2600, 3600, 4600, 1601, 2601, 3601, 4601, 1602, 2602, 3602, 4602,
+ 1510, 2510, 3510, 4510, 1511, 2511, 3511, 4511, 1512, 2512, 3512, 4512, 1610, 2610, 3610, 4610, 1611, 2611, 3611, 4611, 1612, 2612, 3612, 4612,
+ 1520, 2520, 3520, 4520, 1521, 2521, 3521, 4521, 1522, 2522, 3522, 4522, 1620, 2620, 3620, 4620, 1621, 2621, 3621, 4621, 1622, 2622, 3622, 4622
+ },
+ raster.getPixels(7, 7, 6, 9, (int[]) null)
+ );
+
+ // Repeat the test with a custom band selection.
+ result = processor.aggregateBands(Arrays.asList(im1, im2, im1), Arrays.asList(null, new int[] { 1 }, new int[] { 0 }));
+ assertNotNull(result);
+ assertArrayEquals(new int[] { 7, 7, 6, 9, 3, 3, 1, 2 }, new int[] { result.getMinX(), result.getMinY(), result.getWidth(), result.getHeight(), result.getTileWidth(), result.getTileHeight(), result.getMinTileX(), result.getMinTileY()});
+ raster = result.getData();
+ assertEquals(4, raster.getNumBands());
+ assertEquals(new Rectangle(7, 7, 6, 9), raster.getBounds());
+
+ assertArrayEquals(
+ new int[] {
+ // Tile 1 Tile 2
+ 1100, 2100, 4100, 1100, 1101, 2101, 4101, 1101, 1102, 2102, 4102, 1102, 1200, 2200, 4200, 1200, 1201, 2201, 4201, 1201, 1202, 2202, 4202, 1202,
+ 1110, 2110, 4110, 1110, 1111, 2111, 4111, 1111, 1112, 2112, 4112, 1112, 1210, 2210, 4210, 1210, 1211, 2211, 4211, 1211, 1212, 2212, 4212, 1212,
+ 1120, 2120, 4120, 1120, 1121, 2121, 4121, 1121, 1122, 2122, 4122, 1122, 1220, 2220, 4220, 1220, 1221, 2221, 4221, 1221, 1222, 2222, 4222, 1222,
+ // Tile 3 Tile 4
+ 1300, 2300, 4300, 1300, 1301, 2301, 4301, 1301, 1302, 2302, 4302, 1302, 1400, 2400, 4400, 1400, 1401, 2401, 4401, 1401, 1402, 2402, 4402, 1402,
+ 1310, 2310, 4310, 1310, 1311, 2311, 4311, 1311, 1312, 2312, 4312, 1312, 1410, 2410, 4410, 1410, 1411, 2411, 4411, 1411, 1412, 2412, 4412, 1412,
+ 1320, 2320, 4320, 1320, 1321, 2321, 4321, 1321, 1322, 2322, 4322, 1322, 1420, 2420, 4420, 1420, 1421, 2421, 4421, 1421, 1422, 2422, 4422, 1422,
+ // Tile 5 Tile 6
+ 1500, 2500, 4500, 1500, 1501, 2501, 4501, 1501, 1502, 2502, 4502, 1502, 1600, 2600, 4600, 1600, 1601, 2601, 4601, 1601, 1602, 2602, 4602, 1602,
+ 1510, 2510, 4510, 1510, 1511, 2511, 4511, 1511, 1512, 2512, 4512, 1512, 1610, 2610, 4610, 1610, 1611, 2611, 4611, 1611, 1612, 2612, 4612, 1612,
+ 1520, 2520, 4520, 1520, 1521, 2521, 4521, 1521, 1522, 2522, 4522, 1522, 1620, 2620, 4620, 1620, 1621, 2621, 4621, 1621, 1622, 2622, 4622, 1622
+ },
+ raster.getPixels(7, 7, 6, 9, (int[]) null)
+ );
+ }
+
+ private ImageProcessor processor() { return new ImageProcessor(); }
+
+ /**
+ * Initialize all bands of all input images with a "BTYX" pattern where:
+ * <ol>
+ * <li>
+ * "B" is the band index over all encountered images.
+ * It means that be start at 1 for the first band of first encountered image,
+ * and then it is incremented for each band of each encountered image
+ * </li>
+ * <li>"TYX" is defined by {@link TiledImageMock#initializeAllTiles(int...)}</li>
+ * </ol>
+ */
+ private void init(TiledImageMock... images) {
+ int b = 1;
+ for (TiledImageMock image : images) {
+ int[] allBands = IntStream.range(0, image.getSampleModel().getNumBands()).toArray();
+ image.initializeAllTiles(allBands);
+ final int cursor = b;
+ updateValues(image, (band, sample) -> (cursor + band) * 1000 + sample);
+ b += allBands.length;
+ }
+ }
+
+ /**
+ * Change pixel values of input image with provided binary operator.
+ *
+ * @param target The image to update (mutated directly)
+ * @param valueUpdate Operator updating current sample value.
+ * It receives as input the current band index (0-based) and the value associated to this band on the current pixel (the current sample value).
+ * It must return the new value to associate to the pixel.
+ */
+ private void updateValues(WritableRenderedImage target, IntBinaryOperator valueUpdate) {
+ try (WritablePixelIterator it = new PixelIterator.Builder().createWritable(target)) {
+ int[] pixel = new int[it.getNumBands()];
+ while (it.next()) {
+ it.getPixel(pixel);
+ for (int i = 0; i < pixel.length; i++) pixel[i] = valueUpdate.applyAsInt(i, pixel[i]);
+ it.setPixel(pixel);
+ }
+ }
+ }
+}
+
diff --git a/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java b/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
index 78e4a38b3e..2f00f9990e 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
@@ -101,6 +101,7 @@ import org.junit.runners.Suite;
org.apache.sis.image.ResamplingGridTest.class,
org.apache.sis.image.ResampledImageTest.class,
org.apache.sis.image.MaskedImageTest.class,
+ org.apache.sis.image.BandAggregateImageTest.class,
org.apache.sis.image.BandedSampleConverterTest.class,
org.apache.sis.image.ImageCombinerTest.class,
org.apache.sis.image.ImageProcessorTest.class,