This is an automated email from the ASF dual-hosted git repository. desruisseaux pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
commit 08e4257586fcb0736792d2b72dfbbf0e3746b8a2 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Fri Dec 13 19:17:34 2024 +0100 Better checks of ColorModel properties before to write a GeoTIFF file. --- .../sis/coverage/privy/ColorModelFactory.java | 106 +++++++++---- .../apache/sis/coverage/privy/ImageUtilities.java | 16 -- .../main/org/apache/sis/image/Colorizer.java | 5 +- .../sis/storage/geotiff/ImageFileDirectory.java | 91 +++++++---- .../org/apache/sis/storage/geotiff/Writer.java | 17 ++- .../storage/geotiff/writer/ReformattedImage.java | 170 ++++++++++++++------- .../org/apache/sis/storage/geotiff/WriterTest.java | 2 +- .../sis/storage/sql/postgis/RasterReader.java | 2 +- .../apache/sis/io/stream/HyperRectangleWriter.java | 70 +++++++-- .../org/apache/sis/storage/esri/RasterStore.java | 8 +- .../org/apache/sis/storage/gdal/TiledResource.java | 2 +- 11 files changed, 337 insertions(+), 152 deletions(-) diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelFactory.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelFactory.java index 2f9750f88b..a17bb04d1d 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelFactory.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelFactory.java @@ -163,6 +163,7 @@ public final class ColorModelFactory { * * @see #createPiecewise(int, int, int, ColorsForRange[]) */ + @SuppressWarnings("LocalVariableHidesMemberVariable") private ColorModelFactory(final int dataType, final int numBands, final int visibleBand, final ColorsForRange[] colors) { this.dataType = dataType; this.numBands = numBands; @@ -595,52 +596,99 @@ public final class ColorModelFactory { * The sample model shall use integer type and have 3 or 4 bands. * This method may return {@code null} if the color model cannot be created. * - * @param model the sample model for which to create a color model. + * @param targetModel the sample model for which to create a color model. + * @param isAlphaPremultiplied whether the alpha value (if present) is premultiplied. * @return the color model, or null if a precondition does not hold. */ - public static ColorModel createRGB(final SampleModel model) { - final int numBands = model.getNumBands(); - if (numBands >= 3 && numBands <= 4 && ImageUtilities.isIntegerType(model)) { + public static ColorModel createRGB(final SampleModel targetModel, final boolean isAlphaPremultiplied) { +check: if (ImageUtilities.isIntegerType(targetModel)) { + final int numBands = targetModel.getNumBands(); + final int alphaBand; + switch (numBands) { + case 3: alphaBand = -1; break; + case 4: alphaBand = 3; break; + default: break check; + } int bitsPerSample = 0; for (int i=0; i<numBands; i++) { - bitsPerSample = Math.max(bitsPerSample, model.getSampleSize(i)); + bitsPerSample = Math.max(bitsPerSample, targetModel.getSampleSize(i)); } - if (bitsPerSample <= Byte.SIZE) { - return createRGB(bitsPerSample, model.getNumDataElements() == 1, numBands > 3); + if (targetModel.getNumDataElements() != 1) { + return createBandedRGB(bitsPerSample, alphaBand, isAlphaPremultiplied); + } else if (bitsPerSample <= Byte.SIZE) { + return createPackedRGB(bitsPerSample, alphaBand, isAlphaPremultiplied); } } return null; } /** - * Creates a RGB color model. The {@code packed} argument should be - * {@code true} for color model used with {@link java.awt.image.SinglePixelPackedSampleModel}, and - * {@code false} for color model used with {@link java.awt.image.BandedSampleModel}. + * Creates a RGB color model for use with {@code SinglePixelPackedSampleModel}. + * Pixel values are packed in a single integer (usually 32-bits) per pixel. + * Color components are separated using a bitmask for each <abbr>RGBA</abbr> value. * - * @param bitsPerSample number of bits per sample, between 1 and 8 (packed) or 32 (banded) inclusive. - * @param packed whether sample values are packed in a single element. - * @param hasAlpha whether the color model should have an alpha channel. - * @return the color model. + * @param bitsPerSample number of bits per sample, between 1 and 8 inclusive. + * @param alphaBand index of the alpha channel (usually the last band), or -1 if none. + * @param isAlphaPremultiplied whether the alpha value (if present) is premultiplied. + * @return color model for use with {@link java.awt.image.SinglePixelPackedSampleModel}. */ - public static ColorModel createRGB(final int bitsPerSample, final boolean packed, final boolean hasAlpha) { - if ((hasAlpha & packed) && bitsPerSample == Byte.SIZE) { + public static ColorModel createPackedRGB(final int bitsPerSample, final int alphaBand, final boolean isAlphaPremultiplied) { + if (bitsPerSample == Byte.SIZE && alphaBand == 3 && !isAlphaPremultiplied) { return ColorModel.getRGBdefault(); } - ArgumentChecks.ensureBetween("bitsPerSample", 1, packed ? Byte.SIZE : Integer.SIZE, bitsPerSample); - final ColorModel cm; - if (packed) { - final int mask = (1 << bitsPerSample) - 1; - cm = new DirectColorModel((hasAlpha ? 4 : 3) * bitsPerSample, - mask << (bitsPerSample * 2), // Red - mask << bitsPerSample, // Green - mask, // Blue - hasAlpha ? mask << (bitsPerSample * 3) : 0); + ArgumentChecks.ensureBetween("bitsPerSample", 1, Byte.SIZE, bitsPerSample); + final int[] masks = new int[4]; // Red, Green, Blue, Alpha masks in that order. + int mask = (1 << bitsPerSample) - 1; // Start with blue = 0xFF (usually). + for (int i=2; i >= 0; i--) { + masks[i] = mask; + mask <<= bitsPerSample; + } + int numBands = 3; + if (alphaBand >= 0) { + System.arraycopy(masks, alphaBand, masks, alphaBand+1, 3 - alphaBand); + masks[alphaBand] = mask; + numBands = 4; + } + int numBits = numBands * bitsPerSample; + int dataType = (numBits <= Byte.SIZE) ? DataBuffer.TYPE_BYTE : + (numBits <= Short.SIZE) ? DataBuffer.TYPE_USHORT : DataBuffer.TYPE_INT; + var cm = new DirectColorModel(ColorSpace.getInstance(ColorSpace.CS_sRGB), + numBits, masks[0], masks[1], masks[2], masks[3], + isAlphaPremultiplied, dataType); + return unique(cm); + } + + /** + * Creates a RGB color model for use with {@code BandedSampleModel}. + * Each color component (sample value) is stored in a separated data element. + * + * <h4>Limitations</h4> + * The current version requires the alpha channel (if present) to be the last band. + * If this condition is not met, this method returns {@code null}. + * + * @param bitsPerSample number of bits per sample, between 1 and 32 inclusive. + * @param alphaBand index of the alpha channel (usually the last band), or -1 if none. + * @param isAlphaPremultiplied whether the alpha value (if present) is premultiplied. + * @return color model for use with {@link java.awt.image.BandedSampleModel}, or {@code null}. + */ + public static ColorModel createBandedRGB(final int bitsPerSample, final int alphaBand, final boolean isAlphaPremultiplied) { + ArgumentChecks.ensureBetween("bitsPerSample", 1, Integer.SIZE, bitsPerSample); + final int[] numBits; + final int transparency; + final boolean hasAlpha = (alphaBand >= 0); + if (hasAlpha) { + if (alphaBand != 3) { + return null; // Limitation documented in method Javadoc. + } + numBits = new int[4]; + transparency = Transparency.TRANSLUCENT; } else { - final int[] numBits = new int[hasAlpha ? 4 : 3]; - Arrays.fill(numBits, bitsPerSample); - cm = new ComponentColorModel(ColorSpace.getInstance(ColorSpace.CS_sRGB), numBits, hasAlpha, false, - hasAlpha ? Transparency.TRANSLUCENT : Transparency.OPAQUE, DataBuffer.TYPE_BYTE); + numBits = new int[3]; + transparency = Transparency.OPAQUE; } + Arrays.fill(numBits, bitsPerSample); + var cm = new ComponentColorModel(ColorSpace.getInstance(ColorSpace.CS_sRGB), + numBits, hasAlpha, isAlphaPremultiplied, transparency, DataBuffer.TYPE_BYTE); return unique(cm); } diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java index 362490293a..3595c187f7 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java @@ -99,22 +99,6 @@ public final class ImageUtilities extends Static { ((long) low) + aoi.height) - aoi.y); } - /** - * Returns whether the given image has an alpha channel. - * - * @param image the image or {@code null}. - * @return whether the image has an alpha channel. - * - * @see #getTransparencyDescription(ColorModel) - */ - public static boolean hasAlpha(final RenderedImage image) { - if (image != null) { - final ColorModel cm = image.getColorModel(); - if (cm != null) return cm.hasAlpha(); - } - return false; - } - /** * Returns the number of bands in the given image, or 0 if the image or its sample model is null. * diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java index 5762390134..e8e2c005bb 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java @@ -157,9 +157,10 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode /** * RGB(A) color model for images storing 8 bits integer on 3 or 4 bands. - * The color model is RGB for image having 3 bands, or ARGB for images having 4 bands. + * The color model is <abbr>RGB</abbr> for image having 3 bands, or <abbr>ARGB</abbr> for images having 4 bands. + * In the latter case, the color components are considered <em>not</em> premultiplied by the alpha value. */ - Colorizer ARGB = (target) -> Optional.ofNullable(ColorModelFactory.createRGB(target.getSampleModel())); + Colorizer ARGB = (target) -> Optional.ofNullable(ColorModelFactory.createRGB(target.getSampleModel(), false)); /** * Creates a colorizer which will interpolate the given colors in the given range of values. diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/ImageFileDirectory.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/ImageFileDirectory.java index 181e8c3aa8..abe78d10dc 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/ImageFileDirectory.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/ImageFileDirectory.java @@ -54,7 +54,6 @@ import org.apache.sis.coverage.grid.GridGeometry; import org.apache.sis.coverage.grid.GridExtent; import org.apache.sis.coverage.privy.ColorModelFactory; import org.apache.sis.coverage.privy.SampleModelFactory; -import org.apache.sis.util.ArraysExt; import org.apache.sis.util.Numbers; import org.apache.sis.util.CharSequences; import org.apache.sis.util.resources.Vocabulary; @@ -99,6 +98,11 @@ final class ImageFileDirectory extends DataCube { */ private static final byte SIGNED = 1, UNSIGNED = 0, FLOAT = 3; + /** + * The band to make visible. May become configurable in a future version. + */ + private static final int VISIBLE_BAND = ColorModelFactory.DEFAULT_VISIBLE_BAND; + /** * Index of the (potentially pyramided) image containing this Image File Directory (IFD). * All reduced-resolution (overviews) images are ignored when computing this index value. @@ -724,7 +728,7 @@ final class ImageFileDirectory extends DataCube { } /* * The number of components per pixel. Usually 1 for bilevel, grayscale, and palette-color images, - * and 3 for RGB images. Default value is 1. + * and 3 for RGB images. Default value is 1. May be greater than 3 if there is extra samples. */ case TAG_SAMPLES_PER_PIXEL: { samplesPerPixel = type.readAsShort(input(), count); @@ -1599,6 +1603,7 @@ final class ImageFileDirectory extends DataCube { * A sample model of different size or number of bands can be derived after construction * by call to one of {@code SampleModel.create…} methods. * + * @param bands should always be {@code null} if this implementation. * @throws DataStoreContentException if the data type is not supported. * * @see SampleModel#createCompatibleSampleModel(int, int) @@ -1713,9 +1718,28 @@ final class ImageFileDirectory extends DataCube { return null; // Let `TileGridResource` derive a model itself. } if (colorModel == null) { - final SampleModel sm = getSampleModel(null); - final int dataType = sm.getDataType(); - final int visibleBand = 0; // May be configurable in a future version. + /* + * The index of the alpha band is relative to extra samples. + * Before to be used, the number of color bands must be added. + * That number depends on the color interpretation. + * + * The alpha channel information should be used for all color models. + * However, this is only partially honored in current implementation. + */ + int alphaBand = -1; + boolean isAlphaPremultiplied = false; + if (extraSamples != null) { + final int n = extraSamples.size(); + for (int i=0; i<n; i++) { + switch (extraSamples.intValue(i)) { + case EXTRA_SAMPLES_ASSOCIATED_ALPHA: isAlphaPremultiplied = true; break; + case EXTRA_SAMPLES_UNASSOCIATED_ALPHA: break; + default: continue; + } + alphaBand = i; + break; + } + } short missing = 0; // Non-zero if there is a warning about missing information. switch (photometricInterpretation) { default: { // For any unrecognized code, fallback on grayscale with 0 as black. @@ -1726,31 +1750,21 @@ final class ImageFileDirectory extends DataCube { missing = TAG_PHOTOMETRIC_INTERPRETATION; break; } - case PHOTOMETRIC_INTERPRETATION_WHITE_IS_ZERO: - case PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO: { - final Color[] colors = {Color.BLACK, Color.WHITE}; - if (photometricInterpretation == PHOTOMETRIC_INTERPRETATION_WHITE_IS_ZERO) { - ArraysExt.swap(colors, 0, 1); - } - double min = 0; - double max = Numerics.bitmask(bitsPerSample); // Exclusive. - if (sampleFormat != UNSIGNED) { - max /= 2; - min = -max; - } - if (minValues != null) min = Math.max(min, minValues.doubleValue(visibleBand)); - if (maxValues != null) max = Math.min(max, maxValues.doubleValue(visibleBand) + 1); - colorModel = ColorModelFactory.createColorScale(dataType, samplesPerPixel, visibleBand, min, max, colors); + case PHOTOMETRIC_INTERPRETATION_WHITE_IS_ZERO: { + createSingleBandColorModel(Color.WHITE, Color.BLACK); + break; + } + case PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO: { + createSingleBandColorModel(Color.BLACK, Color.WHITE); break; } case PHOTOMETRIC_INTERPRETATION_RGB: { - final int numBands = sm.getNumBands(); - if (numBands < 3 || numBands > 4) { - throw new DataStoreContentException(Errors.format(Errors.Keys.UnexpectedValueInElement_2, "numBands", numBands)); + if (alphaBand >= 0) alphaBand += 3; // Must add the number of color bands. + if (getSampleModel(null) instanceof SinglePixelPackedSampleModel) { + colorModel = ColorModelFactory.createPackedRGB(bitsPerSample, alphaBand, isAlphaPremultiplied); + } else { + colorModel = ColorModelFactory.createBandedRGB(bitsPerSample, alphaBand, isAlphaPremultiplied); } - final boolean hasAlpha = (numBands >= 4); - final boolean packed = (sm instanceof SinglePixelPackedSampleModel); - colorModel = ColorModelFactory.createRGB(bitsPerSample, packed, hasAlpha); break; } case PHOTOMETRIC_INTERPRETATION_PALETTE_COLOR: { @@ -1767,18 +1781,39 @@ final class ImageFileDirectory extends DataCube { | ((colorMap.intValue(gi++) & 0xFF00)) | ((colorMap.intValue(bi++) & 0xFF00) >>> Byte.SIZE); } - colorModel = ColorModelFactory.createIndexColorModel(samplesPerPixel, visibleBand, ARGB, - true, Double.isFinite(noData) ? (int) Math.round(noData) : -1); + int transparent = Double.isFinite(noData) ? (int) Math.round(noData) : -1; + colorModel = ColorModelFactory.createIndexColorModel(samplesPerPixel, VISIBLE_BAND, ARGB, true, transparent); break; } } if (missing != 0) { missingTag(missing, "GrayScale", false, true); } + if (colorModel == null) { + createSingleBandColorModel(Color.BLACK, Color.WHITE); + } } return colorModel; } + /** + * Creates a color model for a (theoretically) one-banded image. + * May also be invoked as a fallback for image with more bands if more suitable color model couldn't be created. + */ + private void createSingleBandColorModel(final Color zero, final Color high) throws DataStoreContentException { + double min = 0; + double max = Numerics.bitmask(bitsPerSample); // Exclusive. + switch (sampleFormat) { + default: break; + case FLOAT: max = 1; break; + case SIGNED: max /= 2; min = -max; break; + } + if (minValues != null) min = Math.max(min, minValues.doubleValue(VISIBLE_BAND)); + if (maxValues != null) max = Math.min(max, maxValues.doubleValue(VISIBLE_BAND) + 1); + colorModel = ColorModelFactory.createColorScale(getSampleModel(null).getDataType(), + samplesPerPixel, VISIBLE_BAND, min, max, zero, high); + } + /** * Returns the fill value to be replaced by NaN at reading time. * This is possible only for images of floating point type. diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java index 4dd261c294..ac69b69b37 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java @@ -337,7 +337,7 @@ final class Writer extends IOBase implements Flushable { private TileMatrix writeImageFileDirectory(final ReformattedImage image, final GridGeometry grid, final Metadata metadata, final boolean overview) throws IOException, DataStoreException { - final SampleModel sm = image.visibleBands.getSampleModel(); + final SampleModel sm = image.exportable.getSampleModel(); Compression compression = store.getCompression().orElse(Compression.DEFLATE); if (!ImageUtilities.isIntegerType(sm)) { compression = compression.withPredictor(PREDICTOR_NONE); @@ -355,6 +355,9 @@ final class Writer extends IOBase implements Flushable { if (colorInterpretation == PHOTOMETRIC_INTERPRETATION_PALETTE_COLOR) { numberOfTags++; } + if (image.extraSamples != null) { + numberOfTags++; + } final int sampleFormat = image.getSampleFormat(); final int[] bitsPerSample = sm.getSampleSize(); final int numBands = sm.getNumBands(); @@ -406,12 +409,15 @@ final class Writer extends IOBase implements Flushable { isBigTIFF ? UpdatableWrite.of(output, (long) numberOfTags) : UpdatableWrite.of(output, (short) numberOfTags); - final var tiling = new TileMatrix(image.visibleBands, numPlanes, bitsPerSample, offsetIFD, + final var tiling = new TileMatrix(image.exportable, numPlanes, bitsPerSample, offsetIFD, compression.method, compression.level, compression.predictor); + /* + * Reminder: TIFF tags should be written in increasing numerical order. + */ numberOfTags = 0; writeTag((short) TAG_NEW_SUBFILE_TYPE, (short) TIFFTag.TIFF_LONG, overview ? 1 : 0); - writeTag((short) TAG_IMAGE_WIDTH, (short) TIFFTag.TIFF_LONG, image.visibleBands.getWidth()); - writeTag((short) TAG_IMAGE_LENGTH, (short) TIFFTag.TIFF_LONG, image.visibleBands.getHeight()); + writeTag((short) TAG_IMAGE_WIDTH, (short) TIFFTag.TIFF_LONG, image.exportable.getWidth()); + writeTag((short) TAG_IMAGE_LENGTH, (short) TIFFTag.TIFF_LONG, image.exportable.getHeight()); writeTag((short) TAG_BITS_PER_SAMPLE, (short) TIFFTag.TIFF_SHORT, bitsPerSample); writeTag((short) TAG_COMPRESSION, (short) TIFFTag.TIFF_SHORT, compression.method.code); writeTag((short) TAG_PHOTOMETRIC_INTERPRETATION, (short) TIFFTag.TIFF_SHORT, colorInterpretation); @@ -436,12 +442,13 @@ final class Writer extends IOBase implements Flushable { writeTag((short) TAG_PREDICTOR, (short) TIFFTag.TIFF_SHORT, compression.predictor.code); } if (colorInterpretation == PHOTOMETRIC_INTERPRETATION_PALETTE_COLOR) { - writeColorPalette((IndexColorModel) image.visibleBands.getColorModel(), 1L << bitsPerSample[0]); + writeColorPalette((IndexColorModel) image.exportable.getColorModel(), 1L << bitsPerSample[0]); } writeTag((short) TAG_TILE_WIDTH, /* TIFF_LONG */ tiling, false); writeTag((short) TAG_TILE_LENGTH, /* TIFF_LONG */ tiling, false); writeTag((short) TAG_TILE_OFFSETS, /* TIFF_LONG */ tiling, false); writeTag((short) TAG_TILE_BYTE_COUNTS, /* TIFF_LONG */ tiling, false); + writeTag((short) TAG_EXTRA_SAMPLES, /* TIFF_SHORT */ image.extraSamples); writeTag((short) TAG_SAMPLE_FORMAT, (short) TIFFTag.TIFF_SHORT, sampleFormat); writeTag((short) TAG_S_MIN_SAMPLE_VALUE, (short) TIFFTag.TIFF_FLOAT, statistics[0]); writeTag((short) TAG_S_MAX_SAMPLE_VALUE, (short) TIFFTag.TIFF_FLOAT, statistics[1]); diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java index bb3e9f8452..c9cbff5c26 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java @@ -28,12 +28,12 @@ import org.apache.sis.math.Statistics; import org.apache.sis.image.PlanarImage; import org.apache.sis.image.ImageProcessor; import org.apache.sis.coverage.privy.ImageUtilities; -import org.apache.sis.storage.IncompatibleResourceException; +import org.apache.sis.io.stream.HyperRectangleWriter; /** - * An image prepared for writing with bands separated in the way they are stored in a TIFF file. - * The TIFF specification stores visible bands, alpha channel and extra bands separately. + * An image prepared for writing in a TIFF file. The TIFF specification puts some restrictions on tile size + * (must be a multiple of 16), and {@code HyperRectangleWriter} has some other restrictions on data layout. * * @todo Force tile size to multiple of 16. * @@ -41,14 +41,30 @@ import org.apache.sis.storage.IncompatibleResourceException; */ public final class ReformattedImage { /** - * The main image with visible bands. + * Number of color bands before the extra bands. This number does not include the alpha channel, + * which is considered an extra band in TIFF. This number does not apply to gray scale images, + * which use only one band. */ - public final RenderedImage visibleBands; + private static final int NUM_COLOR_BANDS = 3; - /* - * TODO: alpha and extra bands not yet stored. - * This will be handled in a future version. + /** + * Whether the set of color bands includes only one band: gray scale or color palette. + * If {@code false}, then the number of color bands is {@value #NUM_COLOR_BANDS}. + * Alpha channel and extra components are ignored. + */ + private final boolean singleBand; + + /** + * The values to write in the {@code ExtraSamples} TIFF tag, or {@code null} if none. + * Values are some of the {@code EXTRA_SAMPLES_*} constants. + * The alpha channel, if presents, is declared here. + */ + public short[] extraSamples; + + /** + * The image reformatted in a way that can be written in a TIFF file. */ + public final RenderedImage exportable; /** * Formats the given image into something that can be written in a GeoTIFF file. @@ -58,27 +74,62 @@ public final class ReformattedImage { * @param image the image to separate into visible, alpha and extra bands. * @param processor supplier of the image processor to use if the image must be transformed. */ - public ReformattedImage(final RenderedImage image, final Supplier<ImageProcessor> processor) { + public ReformattedImage(RenderedImage image, final Supplier<ImageProcessor> processor) { + int alphaBand = -1; + boolean isAlphaPremultiplied = false; final int numBands = ImageUtilities.getNumBands(image); -select: if (numBands > 1) { - final int[] bands; - final int band = ImageUtilities.getVisibleBand(image); - if (band >= 0) { - bands = new int[] {band}; - } else { - int max = 3; // TIFF can store only 3 bands (ignoring extra bands). - if (ImageUtilities.hasAlpha(image)) { - max = Math.min(max, numBands - 1); // The alpha band is always the last one. - } - if (numBands <= max) { - break select; + final int visibleBand = ImageUtilities.getVisibleBand(image); + if (visibleBand >= 0) { + /* + * Indexed color model or gray scale image. This is conceptually a single band. + * But as an extension, Apache SIS allows the image to contain additional bands + * where only one band is shown. We need to order the visible band first. + * All other bands will be extra samples. + */ + singleBand = true; + if (visibleBand != 0) { + final int[] bands = ArraysExt.range(0, numBands); + System.arraycopy(bands, 0, bands, 1, visibleBand); + bands[0] = visibleBand; + image = processor.get().selectBands(image, bands); // Reorder bands without copy. + } + } else { + /* + * Any case with 2 or more bands. GeoTIFF wants us to store either 1 or 3 bands. + * So if we cannot store 3 bands (excluding alpha channel), we will handle the + * image as gray scale. + */ + final ColorModel cm = image.getColorModel(); + if (cm != null) { + isAlphaPremultiplied = cm.isAlphaPremultiplied(); + alphaBand = cm.getNumColorComponents(); // Alpha channel is right after color components. + if (alphaBand >= cm.getNumComponents()) { + alphaBand = -1; } - bands = ArraysExt.range(0, max); } - visibleBands = processor.get().selectBands(image, bands); - return; + singleBand = numBands < (alphaBand >= 0 ? 1 + NUM_COLOR_BANDS : NUM_COLOR_BANDS); } - visibleBands = image; + /* + * Check if there is any extra samples. If yes, prepare an `extraSamples` array with all + * values initialized to `EXTRA_SAMPLES_UNSPECIFIED` (value 0) except for the alpha channel. + */ + final int numColors = singleBand ? 1 : Math.min(numBands, NUM_COLOR_BANDS); + if (numBands > numColors) { + extraSamples = new short[numBands - numColors]; + if (alphaBand >= 0) { + extraSamples[alphaBand - numColors] = isAlphaPremultiplied + ? (short) EXTRA_SAMPLES_ASSOCIATED_ALPHA + : (short) EXTRA_SAMPLES_UNASSOCIATED_ALPHA; + } + } + /* + * If the image cannot be written directly, reformat. Because this operation + * forces the copy of pixel values, it should be executed in last resort. + */ + if (!HyperRectangleWriter.Builder.isSupported(image.getSampleModel())) { + // TODO: reformat the image here. + } + exportable = image; } /** @@ -90,7 +141,7 @@ select: if (numBands > 1) { * Array elements may be {@code null} if there is no statistics. */ public double[][] statistics(final int numBands) { - final Object property = visibleBands.getProperty(PlanarImage.STATISTICS_KEY); + final Object property = exportable.getProperty(PlanarImage.STATISTICS_KEY); found: if (property instanceof Statistics[]) { final var stats = (Statistics[]) property; final var min = new double[numBands]; @@ -112,7 +163,7 @@ found: if (property instanceof Statistics[]) { * @return One of {@code SAMPLE_FORMAT_*} constants. */ public int getSampleFormat() { - final SampleModel sm = visibleBands.getSampleModel(); + final SampleModel sm = exportable.getSampleModel(); if (ImageUtilities.isUnsignedType(sm)) return SAMPLE_FORMAT_UNSIGNED_INTEGER; if (ImageUtilities.isIntegerType(sm)) return SAMPLE_FORMAT_SIGNED_INTEGER; return SAMPLE_FORMAT_FLOATING_POINT; @@ -122,40 +173,47 @@ found: if (property instanceof Statistics[]) { * Returns the TIFF color interpretation. * * @return One of {@code PHOTOMETRIC_INTERPRETATION_*} constants. - * @throws IncompatibleResourceException if the color model is not supported. */ - public int getColorInterpretation() throws IncompatibleResourceException { - final ColorModel cm = visibleBands.getColorModel(); - if (cm instanceof IndexColorModel) { - final var icm = (IndexColorModel) cm; - final int last = icm.getMapSize() - 1; - final float scale = 255f / last; - boolean white = true; - boolean black = true; - for (int i=0; i <= last; i++) { - final int expected = Math.round(i * scale); - if (black) black = icm.getRGB( i) == expected; - if (white) white = icm.getRGB(last-i) == expected; - if (!(black | white)) break; + public int getColorInterpretation() { + final ColorModel cm = exportable.getColorModel(); + if (singleBand) { + if (cm instanceof IndexColorModel) { + final var icm = (IndexColorModel) cm; + final int last = icm.getMapSize() - 1; + final float scale = 255f / last; + boolean white = true; + boolean black = true; + for (int i=0; i <= last; i++) { + final int expected = Math.round(i * scale); + if (black) black = icm.getRGB( i) == expected; + if (white) white = icm.getRGB(last-i) == expected; + if (!(black | white)) break; + } + if (black) return PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO; + if (white) return PHOTOMETRIC_INTERPRETATION_WHITE_IS_ZERO; + return PHOTOMETRIC_INTERPRETATION_PALETTE_COLOR; } - if (black) return PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO; - if (white) return PHOTOMETRIC_INTERPRETATION_WHITE_IS_ZERO; - return PHOTOMETRIC_INTERPRETATION_PALETTE_COLOR; + return PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO; } if (cm != null) { - switch (cm.getColorSpace().getType()) { - case ColorSpace.TYPE_GRAY: return PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO; - case ColorSpace.TYPE_RGB: return PHOTOMETRIC_INTERPRETATION_RGB; - case ColorSpace.TYPE_CMY: return PHOTOMETRIC_INTERPRETATION_CMYK; - case ColorSpace.TYPE_Lab: return PHOTOMETRIC_INTERPRETATION_CIELAB; - case ColorSpace.TYPE_YCbCr: return PHOTOMETRIC_INTERPRETATION_Y_CB_CR; - // A future version may add support for more color models. + /* + * All color interpretations returned in this block shall expect 3 bands. + * Gray scale images are handled as RGB (should not be a concern, because + * they should have been handled in the previous block). If a color space + * has 4 or more components, all components after 3 are extra samples. + */ + final ColorSpace cs = cm.getColorSpace(); + if (cs != null) { + switch (cs.getType()) { + // ColorSpace.TYPE_GRAY: should never happen here. + case ColorSpace.TYPE_RGB: return PHOTOMETRIC_INTERPRETATION_RGB; + case ColorSpace.TYPE_CMY: return PHOTOMETRIC_INTERPRETATION_CMYK; // Black stored as extra sample. + case ColorSpace.TYPE_Lab: return PHOTOMETRIC_INTERPRETATION_CIELAB; + case ColorSpace.TYPE_YCbCr: return PHOTOMETRIC_INTERPRETATION_Y_CB_CR; + // A future version may add support for more color models. + } } } - if (ImageUtilities.getNumBands(visibleBands) >= 3) { - return PHOTOMETRIC_INTERPRETATION_RGB; - } else { - return PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO; - } + return PHOTOMETRIC_INTERPRETATION_RGB; // Default value for unknown color space. } } diff --git a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java index eb415c5647..5577acd807 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java @@ -237,7 +237,7 @@ public final class WriterTest extends TestCase { @Test public void testUntiledRGB() throws IOException, DataStoreException { initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 3, 1, 1); - image.setColorModel(ColorModelFactory.createRGB(image.getSampleModel())); + image.setColorModel(ColorModelFactory.createRGB(image.getSampleModel(), false)); writeImage(); verifyHeader(false, IOBase.LITTLE_ENDIAN); verifyImageFileDirectory(Writer.COMMON_NUMBER_OF_TAGS - 1, // One less tag because stripped layout. diff --git a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/RasterReader.java b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/RasterReader.java index 84ad97dc08..0827136e77 100644 --- a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/RasterReader.java +++ b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/RasterReader.java @@ -316,7 +316,7 @@ public final class RasterReader extends RasterFormat { final int dataType = sm.getDataType(); final int numBands = sm.getNumBands(); if ((numBands == 3) && (dataType == DataBuffer.TYPE_BYTE)) { - cm = ColorModelFactory.createRGB(Byte.SIZE, false, false); + cm = ColorModelFactory.createBandedRGB(Byte.SIZE, -1, false); } else { final int visibleBand = 0; // Arbitrary value (could be configurable). final double minimum, maximum; diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/io/stream/HyperRectangleWriter.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/io/stream/HyperRectangleWriter.java index 5bdd13ea3f..61125670af 100644 --- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/io/stream/HyperRectangleWriter.java +++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/io/stream/HyperRectangleWriter.java @@ -188,6 +188,22 @@ public class HyperRectangleWriter { public Builder() { } + /** + * Returns whether the given sample model is supported. If this method returns {@code true}, + * then invoking a {@code create(…)} method should not throw {@link RasterFormatException}. + * + * @param sm the sample model of the rasters to write, or {@code null}. + * @return whether the given sample model is non-null and supported. + * + * @see #create(SampleModel) + */ + public static boolean isSupported(final SampleModel sm) { + if (sm instanceof ComponentSampleModel) return true; + if (sm instanceof SinglePixelPackedSampleModel) return isSupported(null, (SinglePixelPackedSampleModel) sm); + if (sm instanceof MultiPixelPackedSampleModel) return isSupported(null, (MultiPixelPackedSampleModel) sm); + return false; + } + /** * Creates a new writer for raster data described by the given sample model and strides. * The {@link #pixelStride} and {@link #scanlineStride} fields must be set before this method is invoked. @@ -277,17 +293,32 @@ public class HyperRectangleWriter { bankOffsets = bankIndices; pixelStride = 1; scanlineStride = sm.getScanlineStride(); - final int[] d = sm.getBitMasks(); + if (isSupported(this, sm)) { + return create(sm, null); + } else { + throw new RasterFormatException(sm.toString()); + } + } + + /** + * Tests whether the given sample model is supported by this builder. + * + * @param builder the builder in which to store information, or {@code null} if none. + * @param sm the sample model of the rasters to test for support. + * @return whether the given sample model is supported. + */ + private static boolean isSupported(final Builder builder, final SinglePixelPackedSampleModel sm) { /* * If there is only one band, it is okay to store the values using the current data type. * We require that all bits are used because otherwise, there is a risk to write garbage * and we are not sure that the destination format can encode the mask. */ final int dataSize = DataBuffer.getDataTypeSize(sm.getDataType()); + final int[] d = sm.getBitMasks(); if (d.length == 1) { int mask = (int) ((1L << dataSize) - 1); if ((d[0] & mask) == mask) { - return create(sm, null); + return true; } } /* @@ -295,17 +326,19 @@ public class HyperRectangleWriter { * many formats such as GeoTIFF will interpret those values as bytes. */ if ((dataSize % (d.length * Byte.SIZE)) != 0) { - throw new RasterFormatException(sm.toString()); + return false; } int mask = 0xFF; for (int i = d.length; --i >= 0;) { if (d[i] != mask) { - throw new RasterFormatException(sm.toString()); + return false; } mask <<= Byte.SIZE; } - requiresBigEndian = true; - return create(sm, null); + if (builder != null) { + builder.requiresBigEndian = true; + } + return true; } /** @@ -336,18 +369,35 @@ public class HyperRectangleWriter { bankOffsets = bankIndices; pixelStride = 1; scanlineStride = sm.getScanlineStride(); + if (isSupported(this, sm)) { + return create(sm, null); + } else { + throw new RasterFormatException(sm.toString()); + } + } + + /** + * Tests whether the given sample model is supported by this builder. + * + * @param builder the builder in which to store information, or {@code null} if none. + * @param sm the sample model of the rasters to test for support. + * @return whether the given sample model is supported. + */ + private static boolean isSupported(final Builder builder, final MultiPixelPackedSampleModel sm) { final int[] d = sm.getSampleSize(); if (d.length == 1) { final int sampleSize = d[0]; if (sm.getPixelBitStride() == sampleSize) { final int dataSize = DataBuffer.getDataTypeSize(sm.getDataType()); if (dataSize % sampleSize == 0) { // Check that all bits are used. - requiresBigEndian = Math.max(sampleSize, Byte.SIZE) != dataSize; - return create(sm, null); + if (builder != null) { + builder.requiresBigEndian = Math.max(sampleSize, Byte.SIZE) != dataSize; + } + return true; } } } - throw new RasterFormatException(sm.toString()); + return false; } /** @@ -358,6 +408,8 @@ public class HyperRectangleWriter { * @param sm the sample model of the rasters to write. * @return writer for rasters using the specified sample model (never {@code null}). * @throws RasterFormatException if the given sample model is not supported. + * + * @see #isSupported(SampleModel) */ public HyperRectangleWriter create(final SampleModel sm) { if (sm instanceof ComponentSampleModel) return create((ComponentSampleModel) sm); diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java index 75e4d8c67f..5fea017665 100644 --- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java +++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java @@ -396,16 +396,16 @@ abstract class RasterStore extends PRJDataStore implements GridCoverageResource */ if (band == VISIBLE_BAND) { if (isRGB) { - colorModel = ColorModelFactory.createRGB(sm); // Should never be null. + colorModel = ColorModelFactory.createRGB(sm, false); // Should never be null. } else { try { colorModel = readColorMap(dataType, (int) (maximum + 1), bands.length); } catch (URISyntaxException | IOException | NumberFormatException e) { cannotReadAuxiliaryFile(CLR, e); } - if (colorModel == null) { - colorModel = ColorModelFactory.createGrayScale(dataType, bands.length, band, minimum, maximum); - } + } + if (colorModel == null) { + colorModel = ColorModelFactory.createGrayScale(dataType, bands.length, band, minimum, maximum); } } } diff --git a/optional/src/org.apache.sis.storage.gdal/main/org/apache/sis/storage/gdal/TiledResource.java b/optional/src/org.apache.sis.storage.gdal/main/org/apache/sis/storage/gdal/TiledResource.java index 75050c3f36..7e72b37161 100644 --- a/optional/src/org.apache.sis.storage.gdal/main/org/apache/sis/storage/gdal/TiledResource.java +++ b/optional/src/org.apache.sis.storage.gdal/main/org/apache/sis/storage/gdal/TiledResource.java @@ -473,7 +473,7 @@ final class TiledResource extends TiledGridResource { */ } if ((red | green | blue) >= 0) { - colorModel = ColorModelFactory.createRGB(dataType.numBits, false, alpha >= 0); + colorModel = ColorModelFactory.createBandedRGB(dataType.numBits, alpha, false); // TODO: needs custom color model if too many bands, or if order is not (A)RGB. } else if (palette != null) { colorModel = ColorModelFactory.createIndexColorModel(selectedBands.length, paletteIndex, palette, true, -1);