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 9824159f78319d2103823c90b2b80ef4d0b75c79 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Sun Dec 5 15:19:50 2021 +0100 Handle reduced-resolution (overview) images as levels in a pyramid of images (Cloud Optimized GeoTIFF convention). --- .../org/apache/sis/coverage/grid/GridGeometry.java | 5 +- .../sis/internal/geotiff/SchemaModifier.java | 5 + .../org/apache/sis/storage/geotiff/DataCube.java | 5 +- .../apache/sis/storage/geotiff/GeoTiffStore.java | 13 +- .../sis/storage/geotiff/GridGeometryBuilder.java | 42 ++--- .../sis/storage/geotiff/ImageFileDirectory.java | 171 +++++++++++++----- .../sis/storage/geotiff/MultiResolutionImage.java | 190 ++++++++++++++++++++ .../org/apache/sis/storage/geotiff/Reader.java | 195 ++++++++++++++------- 8 files changed, 485 insertions(+), 141 deletions(-) diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java index 25eddfd..d635d13 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java +++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java @@ -337,8 +337,11 @@ public class GridGeometry implements LenientComparable, Serializable { * @throws TransformException if the math transform can not compute the geospatial envelope from the grid extent. * * @see GridDerivation#subgrid(Envelope, double...) + * + * @since 1.2 */ - GridGeometry(final GridGeometry other, final GridExtent extent, final MathTransform toOther) throws TransformException { + public GridGeometry(final GridGeometry other, final GridExtent extent, final MathTransform toOther) throws TransformException { + ArgumentChecks.ensureNonNull("other", other); final int dimension = other.getDimension(); this.extent = extent; ensureDimensionMatches(dimension, extent); diff --git a/storage/sis-geotiff/src/main/java/org/apache/sis/internal/geotiff/SchemaModifier.java b/storage/sis-geotiff/src/main/java/org/apache/sis/internal/geotiff/SchemaModifier.java index 11e1650..8efa55b 100644 --- a/storage/sis-geotiff/src/main/java/org/apache/sis/internal/geotiff/SchemaModifier.java +++ b/storage/sis-geotiff/src/main/java/org/apache/sis/internal/geotiff/SchemaModifier.java @@ -30,6 +30,11 @@ import org.opengis.util.GenericName; /** * Modifies the metadata and bands inferred from GeoTIFF tags. * + * <h2>Image indices</h2> + * All image {@code index} arguments in this interfaces starts with 0 for the first (potentially pyramided) image + * are are incremented by 1 after each <em>pyramid</em>, as defined by the cloud Optimized GeoTIFF specification. + * Consequently those indices may differ from TIFF <cite>Image File Directory</cite> (IFD) indices. + * * @todo May move to public API (in revised form) in a future version. * * @author Martin Desruisseaux (Geomatys) diff --git a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/DataCube.java b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/DataCube.java index 8c946ec..bd10a69 100644 --- a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/DataCube.java +++ b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/DataCube.java @@ -31,7 +31,6 @@ import org.apache.sis.internal.geotiff.Compression; import org.apache.sis.internal.storage.TiledGridResource; import org.apache.sis.internal.storage.ResourceOnFileSystem; import org.apache.sis.internal.storage.StoreResource; -import org.apache.sis.util.resources.Errors; import org.apache.sis.math.Vector; @@ -42,7 +41,7 @@ import org.apache.sis.math.Vector; * or a pyramid of images with their overviews used when low resolution images is requested. * * @author Martin Desruisseaux (Geomatys) - * @version 1.1 + * @version 1.2 * @since 1.1 * @module */ @@ -206,7 +205,7 @@ abstract class DataCube extends TiledGridResource implements ResourceOnFileSyste coverage = preload(coverage); } } catch (RuntimeException e) { - throw new DataStoreException(reader.errors().getString(Errors.Keys.CanNotRead_1, filename()), e); + throw reader.store.errorIO(e); } logReadOperation(reader.store.path, coverage.getGridGeometry(), startTime); return coverage; diff --git a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/GeoTiffStore.java b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/GeoTiffStore.java index 9da2e6e..333560a 100644 --- a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/GeoTiffStore.java +++ b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/GeoTiffStore.java @@ -311,8 +311,8 @@ public class GeoTiffStore extends DataStore implements Aggregate { setFormatInfo(builder); int n = 0; try { - ImageFileDirectory dir; - while ((dir = reader.getImageFileDirectory(n++)) != null) { + GridCoverageResource dir; + while ((dir = reader.getImage(n++)) != null) { builder.addFromComponent(dir.getMetadata()); } } catch (IOException e) { @@ -369,9 +369,10 @@ public class GeoTiffStore extends DataStore implements Aggregate { } /** - * Returns the exception to throw when an I/O error occurred. + * Returns the exception to throw when an I/O or other kind of error occurred. + * This method wraps the exception with a {@literal "Can not read <filename>"} message. */ - private DataStoreException errorIO(final IOException e) { + final DataStoreException errorIO(final Exception e) { return new DataStoreException(errors().getString(Errors.Keys.CanNotRead_1, reader.input.filename), e); } @@ -446,7 +447,7 @@ public class GeoTiffStore extends DataStore implements Aggregate { /** Returns element at the given index or returns {@code null} if the index is invalid. */ private GridCoverageResource getImageFileDirectory(final int index) { try { - return reader().getImageFileDirectory(index); + return reader().getImage(index); } catch (IOException e) { throw new BackingStoreException(errorIO(e)); } catch (DataStoreException e) { @@ -474,7 +475,7 @@ public class GeoTiffStore extends DataStore implements Aggregate { cause = e; } if (index > 0) try { - ImageFileDirectory image = reader().getImageFileDirectory(index - 1); + GridCoverageResource image = reader().getImage(index - 1); if (image != null) return image; } catch (IOException e) { throw errorIO(e); diff --git a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/GridGeometryBuilder.java b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/GridGeometryBuilder.java index ac30ee7..ed8c7ad 100644 --- a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/GridGeometryBuilder.java +++ b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/GridGeometryBuilder.java @@ -75,16 +75,11 @@ import org.apache.sis.math.Vector; * So compared to the {@code CELL_CORNER} case, the {@code CELL_CENTER} case has a translation of +0.5 × scale. * * @author Martin Desruisseaux (Geomatys) - * @version 1.0 + * @version 1.2 * @since 1.0 * @module */ final class GridGeometryBuilder extends GeoKeysLoader { - /** - * The reader for which we will create coordinate reference systems. - * This is used for reporting warnings. - */ - private final Reader reader; //////////////////////////////////////////////////////////////////////////////////////// //// //// @@ -173,31 +168,22 @@ final class GridGeometryBuilder extends GeoKeysLoader { //////////////////////////////////////////////////////////////////////////////////////// /** - * The grid geometry to be created by {@link #build(long, long)}. - * It has 2 or 3 dimensions, depending on whether the CRS declares a vertical axis or not. - */ - public GridGeometry gridGeometry; - - /** * Suggested value for a general description of the transformation form grid coordinates to "real world" coordinates. - * This information is obtained as a side-effect of {@link #build(long, long)} call. + * This information is obtained as a side-effect of {@link #build(Reader, long, long)} call. */ private String description; /** * {@code POINT} if {@link GeoKeys#RasterType} is {@link GeoCodes#RasterPixelIsPoint}, * {@code AREA} if it is {@link GeoCodes#RasterPixelIsArea}, or null if unspecified. - * This information is obtained as a side-effect of {@link #build(long, long)} call. + * This information is obtained as a side-effect of {@link #build(Reader, long, long)} call. */ private CellGeometry cellGeometry; /** * Creates a new builder. - * - * @param reader where to report warnings if any. */ - GridGeometryBuilder(final Reader reader) { - this.reader = reader; + GridGeometryBuilder() { } /** @@ -266,16 +252,16 @@ final class GridGeometryBuilder extends GeoKeysLoader { /** * Creates the grid geometry and collect related metadata. * This method shall be invoked exactly once after {@link #validateMandatoryTags()}. - * After this method call (if successful), {@link #gridGeometry} is guaranteed non-null + * After this method call (if successful), the returned value is guaranteed non-null * and can be used as a flag for determining that the build has been completed. * * @param width the image width in pixels. * @param height the image height in pixels. - * @return {@link #gridGeometry}, guaranteed non-null. + * @return the grid geometry, guaranteed non-null. * @throws FactoryException if an error occurred while creating a CRS or a transform. */ @SuppressWarnings("fallthrough") - public GridGeometry build(final long width, final long height) throws FactoryException { + public GridGeometry build(final Reader reader, final long width, final long height) throws FactoryException { CoordinateReferenceSystem crs = null; if (keyDirectory != null) { final CRSBuilder helper = new CRSBuilder(reader); @@ -291,7 +277,7 @@ final class GridGeometryBuilder extends GeoKeysLoader { reader.store.warning(reader.resources().getString(key, reader.store.getDisplayName()), e); } catch (IllegalArgumentException | NoSuchElementException | ClassCastException e) { if (!helper.alreadyReported) { - canNotCreate(e); + canNotCreate(reader, e); } } } @@ -312,6 +298,7 @@ final class GridGeometryBuilder extends GeoKeysLoader { final GridExtent extent = new GridExtent(axisTypes, null, high, true); boolean pixelIsPoint = CellGeometry.POINT.equals(cellGeometry); final MathTransformFactory factory = DefaultFactories.forBuildin(MathTransformFactory.class); + GridGeometry gridGeometry; try { MathTransform gridToCRS; if (affine != null) { @@ -329,7 +316,7 @@ final class GridGeometryBuilder extends GeoKeysLoader { envelope.setToNaN(); } gridGeometry = new GridGeometry(extent, envelope, GridOrientation.HOMOTHETY); - canNotCreate(e); + canNotCreate(reader, e); /* * Note: we catch TransformExceptions because they may be caused by erroneous data in the GeoTIFF file, * but let FactoryExceptions propagate because they are more likely to be a SIS configuration problem. @@ -348,7 +335,7 @@ final class GridGeometryBuilder extends GeoKeysLoader { * * <h4>Pre-requite</h4> * <ul> - * <li>{@link #build(long, long)} must have been invoked successfully before this method.</li> + * <li>{@link #build(Reader, long, long)} must have been invoked successfully before this method.</li> * <li>{@link ImageFileDirectory} must have filled its part of metadata before to invoke this method.</li> * </ul> * @@ -362,10 +349,11 @@ final class GridGeometryBuilder extends GeoKeysLoader { * <li>{@code metadata/referenceSystemInfo}</li> * </ul> * - * @param metadata the helper class where to write metadata values. + * @param gridGeometry the grid geometry computed by {@link #build(Reader, long, long)}. + * @param metadata the helper class where to write metadata values. * @throws NumberFormatException if a numeric value was stored as a string and can not be parsed. */ - public void completeMetadata(final MetadataBuilder metadata) { + public void completeMetadata(final GridGeometry gridGeometry, final MetadataBuilder metadata) { if (metadata.addSpatialRepresentation(description, gridGeometry, true)) { /* * Whether the pixel value is thought of as filling the cell area or is considered as point measurements at @@ -391,7 +379,7 @@ final class GridGeometryBuilder extends GeoKeysLoader { /** * Logs a warning telling that we can not create a grid geometry for the given reason. */ - private void canNotCreate(final Exception e) { + private static void canNotCreate(final Reader reader, final Exception e) { reader.store.warning(reader.resources().getString(Resources.Keys.CanNotComputeGridGeometry_1, reader.input.filename), e); } } diff --git a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/ImageFileDirectory.java b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/ImageFileDirectory.java index 4b55c6b..c536b49 100644 --- a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/ImageFileDirectory.java +++ b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/ImageFileDirectory.java @@ -36,6 +36,7 @@ import org.opengis.metadata.citation.DateType; import org.opengis.util.FactoryException; import org.opengis.util.GenericName; import org.opengis.referencing.operation.TransformException; +import org.apache.sis.referencing.operation.transform.MathTransforms; import org.apache.sis.internal.geotiff.Resources; import org.apache.sis.internal.geotiff.Predictor; import org.apache.sis.internal.geotiff.Compression; @@ -73,7 +74,7 @@ import org.apache.sis.image.DataType; * @author Johann Sorel (Geomatys) * @author Thi Phuong Hao Nguyen (VNSC) * @author Martin Desruisseaux (Geomatys) - * @version 1.1 + * @version 1.2 * * @see <a href="http://www.awaresystems.be/imaging/tiff/tifftags.html">TIFF Tag Reference</a> * @@ -95,7 +96,14 @@ final class ImageFileDirectory extends DataCube { private static final byte SIGNED = 1, UNSIGNED = 0, FLOAT = 3; /** - * Index of this Image File Directory. + * Index of the (potentially pyramided) image containing this Image File Directory (IFD). + * All reduced-resolution (overviews) images are ignored when computing this index value. + * If the TIFF file does not contain reduced-resolution (overview) images, then + * {@code index} value is the same as the index of this IFD in the TIFF file. + * + * <p>If this IFD is a reduced-resolution (overview) image, then this index is off by one. + * It has the value of the next pyramid. This is an artifact of the way index is computed + * but should be invisible to user because they should not handle overviews directly.</p> */ private final int index; @@ -124,11 +132,26 @@ final class ImageFileDirectory extends DataCube { private boolean isValidated; /** + * A general indication of the kind of data contained in this subfile, mainly useful when there + * are multiple subfiles in a single TIFF file. This field is made up of a set of 32 flag bits. + * + * Bit 0 is 1 if the image is a reduced-resolution version of another image in this TIFF file. + * Bit 1 is 1 if the image is a single page of a multi-page image (see PageNumber). + * Bit 2 is 1 if the image defines a transparency mask for another image in this TIFF file (see PhotometricInterpretation). + * Bit 4 indicates MRC imaging model as described in ITU-T recommendation T.44 [T.44] (See ImageLayer tag) - RFC 2301. + * + * @see #isReducedResolution() + */ + private int subfileType; + + /** * The size of the image described by this FID, or -1 if the information has not been found. * The image may be much bigger than the memory capacity, in which case the image shall be tiled. * * <p><b>Note:</b> * the {@link #imageHeight} attribute is named {@code ImageLength} in TIFF specification.</p> + * + * @see #getExtent() */ private long imageWidth = -1, imageHeight = -1; @@ -399,12 +422,20 @@ final class ImageFileDirectory extends DataCube { */ private GridGeometryBuilder referencing() { if (referencing == null) { - referencing = new GridGeometryBuilder(reader); + referencing = new GridGeometryBuilder(); } return referencing; } /** + * The grid geometry created by {@link GridGeometryBuilder#build(Reader, long, long)}. + * It has 2 or 3 dimensions, depending on whether the CRS declares a vertical axis or not. + * + * @see #getGridGeometry() + */ + private GridGeometry gridGeometry; + + /** * The sample dimensions, or {@code null} if not yet created. * * @see #getSampleDimensions() @@ -428,9 +459,10 @@ final class ImageFileDirectory extends DataCube { /** * Creates a new image file directory. + * The index arguments is used for metadata identifier only. * * @param reader information about the input stream to read, the metadata and the character encoding. - * @param index the image index as a sequence number starting with 0 for the first image. + * @param index the pyramided image index as a sequence number starting with 0 for the first pyramid. */ ImageFileDirectory(final Reader reader, final int index) { super(reader); @@ -453,28 +485,25 @@ final class ImageFileDirectory extends DataCube { } /** - * Returns the identifier, creating it when first needed. - * This method must be invoked in a synchronized block. - */ - private GenericName identifier() throws DataStoreException { - if (identifier == null) { - final GenericName name = reader.nameFactory.createLocalName(reader.store.namespace(), String.valueOf(index + 1)); - identifier = reader.store.customizer.customize(index, name); - if (identifier == null) identifier = name; - } - return identifier; - } - - /** - * Returns the identifier as a sequence number in the namespace of the {@link GeoTiffStore}. - * The first image has the sequence number "1". + * Returns the identifier in the namespace of the {@link GeoTiffStore}. + * The first image has the sequence number "1", optionally customized. + * Reduced-resolution (overviews) images have no identifier. * * @see #getMetadata() */ @Override public Optional<GenericName> getIdentifier() throws DataStoreException { synchronized (getSynchronizationLock()) { - return Optional.of(identifier()); + if (identifier == null) { + if (isReducedResolution()) { + return Optional.empty(); + } + final String id = String.valueOf(index + 1); + final GenericName name = reader.nameFactory.createLocalName(reader.store.namespace(), id); + identifier = reader.store.customizer.customize(index, name); + if (identifier == null) identifier = name; + } + return Optional.of(identifier); } } @@ -765,7 +794,7 @@ final class ImageFileDirectory extends DataCube { * Bit 4 indicates MRC imaging model as described in ITU-T recommendation T.44 [T.44] (See ImageLayer tag) - RFC 2301. */ case Tags.NewSubfileType: { - // TODO + subfileType = type.readInt(input(), count); break; } /* @@ -775,7 +804,13 @@ final class ImageFileDirectory extends DataCube { * 3 = a single page of a multi-page image (see PageNumber). */ case Tags.SubfileType: { - // TODO + final int value = type.readInt(input(), count); + switch (value) { + default: return value; // Warning to be reported by the caller. + case 1: subfileType &= ~1; break; + case 2: subfileType |= 1; break; + case 3: subfileType |= 2; break; + } break; } @@ -1316,7 +1351,16 @@ final class ImageFileDirectory extends DataCube { */ @Override protected Metadata createMetadata() throws DataStoreException { - metadata.addTitle(identifier().toString()); + final MetadataBuilder metadata = this.metadata; + if (metadata == null) { + /* + * We enter in this block only if an exception occurred during the first attempt to build metadata. + * If the user insists for getting metadata, fallback on the default (less complete) implementation. + */ + return super.createMetadata(); + } + this.metadata = null; // Clear now in case an exception happens. + getIdentifier().ifPresent((id) -> metadata.addTitle(id.toString())); /* * Add information about the file format. * @@ -1333,7 +1377,8 @@ final class ImageFileDirectory extends DataCube { * * Destination: metadata/contentInfo/attributeGroup/attribute */ - metadata.newCoverage(reader.store.customizer.isElectromagneticMeasurement(index)); + final boolean isIndexValid = !isReducedResolution(); + metadata.newCoverage(isIndexValid && reader.store.customizer.isElectromagneticMeasurement(index)); final List<SampleDimension> sampleDimensions = getSampleDimensions(); for (int band = 0; band < samplesPerPixel; band++) { metadata.addNewBand(sampleDimensions.get(band)); @@ -1392,16 +1437,45 @@ final class ImageFileDirectory extends DataCube { } catch (TransformException e) { warning(e); } - referencing.completeMetadata(metadata); // Must be after `getGridGeometry()`. + referencing.completeMetadata(gridGeometry, metadata); } /* * End of metadata construction from TIFF tags. */ final DefaultMetadata md = metadata.build(false); - final Metadata c = reader.store.customizer.customize(index, md); - md.transitionTo(DefaultMetadata.State.FINAL); - metadata = null; - return (c != null) ? c : md; + if (isIndexValid) { + final Metadata c = reader.store.customizer.customize(index, md); + md.transitionTo(DefaultMetadata.State.FINAL); + if (c != null) return c; + } + return md; + } + + /** + * Returns {@code true} if this image is a reduced resolution (overview) version + * of another image in this TIFF file. + */ + final boolean isReducedResolution() { + return (subfileType & 1) != 0; + } + + /** + * If this IFD has no grid geometry information, derives a grid geometry by applying a scale factor + * on the grid geometry of another IFD. Information about bands are also copied if compatible. + * This method should be invoked only when {@link #isReducedResolution()} is {@code true}. + * + * @param fullResolution the full-resolution image. + * @param scales <var>size of full resolution image</var> / <var>size of this image</var> for each grid axis. + */ + final void initReducedResolution(final ImageFileDirectory fullResolution, final double[] scales) + throws DataStoreException, TransformException + { + if (referencing == null) { + gridGeometry = new GridGeometry(fullResolution.getGridGeometry(), getExtent(), MathTransforms.scale(scales)); + } + if (samplesPerPixel == fullResolution.samplesPerPixel) { + sampleDimensions = fullResolution.getSampleDimensions(); + } } /** @@ -1411,26 +1485,37 @@ final class ImageFileDirectory extends DataCube { * <h4>Thread-safety</h4> * This method is thread-safe because it can be invoked directly by user. * + * @see #getExtent() * @see #getTileSize() */ @Override public GridGeometry getGridGeometry() throws DataStoreContentException { synchronized (getSynchronizationLock()) { - if (referencing != null) { - GridGeometry gridGeometry = referencing.gridGeometry; - if (gridGeometry == null) try { - gridGeometry = referencing.build(imageWidth, imageHeight); + if (gridGeometry == null) { + if (referencing != null) try { + gridGeometry = referencing.build(reader, imageWidth, imageHeight); } catch (FactoryException e) { throw new DataStoreContentException(reader.resources().getString(Resources.Keys.CanNotComputeGridGeometry_1, filename()), e); + } else { + // Fallback if the TIFF file has no GeoKeys. + gridGeometry = new GridGeometry(getExtent(), null, null); } - return gridGeometry; - } else { - return new GridGeometry(new GridExtent(imageWidth, imageHeight), null, null); } + return gridGeometry; } } /** + * Returns the image width and height without building the full grid geometry. + * + * @see #getTileSize() + * @see #getGridGeometry() + */ + final GridExtent getExtent() { + return new GridExtent(imageWidth, imageHeight); + } + + /** * Returns the ranges of sample values together with the conversion from samples to real values. * * <h4>Thread-safety</h4> @@ -1443,15 +1528,22 @@ final class ImageFileDirectory extends DataCube { if (sampleDimensions == null) { final SampleDimension[] dimensions = new SampleDimension[samplesPerPixel]; final SampleDimension.Builder builder = new SampleDimension.Builder(); - for (int band = 0; band < samplesPerPixel;) { + final boolean isIndexValid = !isReducedResolution(); + for (int band = 0; band < dimensions.length; band++) { NumberRange<?> sampleRange = null; if (minValues != null && maxValues != null) { sampleRange = NumberRange.createBestFit(sampleFormat == FLOAT, minValues.get(Math.min(band, minValues.size()-1)), true, maxValues.get(Math.min(band, maxValues.size()-1)), true); } - dimensions[band] = reader.store.customizer.customize(index, band, - sampleRange, getFillValue(true), builder.setName(++band)); + builder.setName(band + 1); + final SampleDimension sd; + if (isIndexValid) { + sd = reader.store.customizer.customize(index, band, sampleRange, getFillValue(true), builder); + } else { + sd = builder.build(); + } + dimensions[band] = sd; builder.clear(); } sampleDimensions = UnmodifiableArrayList.wrap(dimensions); @@ -1495,6 +1587,7 @@ final class ImageFileDirectory extends DataCube { * Returns the size of tiles. This is also the size of the image sample model. * The number of dimensions is always 2 for {@code ImageFileDirectory}. * + * @see #getExtent() * @see #getSampleModel() */ @Override diff --git a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/MultiResolutionImage.java b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/MultiResolutionImage.java new file mode 100644 index 0000000..5c1b205 --- /dev/null +++ b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/MultiResolutionImage.java @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sis.storage.geotiff; + +import java.util.List; +import java.util.Arrays; +import java.io.IOException; +import org.opengis.referencing.datum.PixelInCell; +import org.opengis.referencing.operation.TransformException; +import org.apache.sis.util.ArraysExt; +import org.apache.sis.coverage.grid.GridExtent; +import org.apache.sis.coverage.grid.GridGeometry; +import org.apache.sis.coverage.grid.GridCoverage; +import org.apache.sis.storage.GridCoverageResource; +import org.apache.sis.storage.DataStoreException; +import org.apache.sis.storage.DataStoreReferencingException; +import org.apache.sis.internal.storage.GridResourceWrapper; +import org.apache.sis.internal.referencing.DirectPositionView; +import org.apache.sis.referencing.operation.matrix.MatrixSIS; + + +/** + * A list of Image File Directory (FID) where the first entry is the image at finest resolution + * and following entries are images at finer resolutions. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +final class MultiResolutionImage extends GridResourceWrapper { + /** + * Descriptions of each <cite>Image File Directory</cite> (IFD) in the GeoTIFF file. + */ + private final ImageFileDirectory[] levels; + + /** + * Resolutions (in units of CRS axes) of each level from finest to coarsest resolution. + * Array elements may be {@code null} if not yet computed. + * + * @see #resolution(int) + * @see #getResolutions() + */ + private final double[][] resolutions; + + /** + * Creates a multi-resolution images with all the given reduced-resolution (overview) images, + * from finest resolution to coarsest resolution. The full-resolution image shall be at index 0. + */ + MultiResolutionImage(final List<ImageFileDirectory> overviews) { + levels = overviews.toArray(new ImageFileDirectory[overviews.size()]); + resolutions = new double[levels.length][]; + } + + /** + * Returns the object on which to perform all synchronizations for thread-safety. + */ + @Override + protected final Object getSynchronizationLock() { + return levels[0].getSynchronizationLock(); + } + + /** + * Creates the resource on which to delegate operations. + * The source is the first image, the one having finest resolution. + * By Cloud Optimized GeoTIFF (COG) convention, this is the image containing metadata (CRS). + * This method is invoked in a synchronized block when first needed and the result is cached. + */ + @Override + protected GridCoverageResource createSource() throws DataStoreException { + try { + return getImageFileDirectory(0); + } catch (IOException e) { + throw levels[0].reader.store.errorIO(e); + } + } + + /** + * Completes and returns the image at the given pyramid level. + * Indices are in the same order as the images appear in the TIFF file, + * with 0 for the full resolution image. + * + * @param index image index (level) in the pyramid, with 0 for finest resolution. + * @return image at the given pyramid level. + */ + private ImageFileDirectory getImageFileDirectory(final int index) throws IOException, DataStoreException { + final ImageFileDirectory dir = levels[index]; + if (dir.hasDeferredEntries) { + dir.reader.resolveDeferredEntries(dir); + } + dir.validateMandatoryTags(); + return dir; + } + + /** + * Returns the resolution (in units of CRS axes) for the given level. + * + * @param level the desired resolution level, numbered from finest to coarsest resolution. + * @return resolution at the specified level, not cloned (caller shall not modify). + */ + private double[] resolution(final int level) throws DataStoreException { + double[] resolution = resolutions[level]; + if (resolution == null) try { + final ImageFileDirectory image = getImageFileDirectory(level); + final ImageFileDirectory base = getImageFileDirectory(0); + final GridGeometry geometry = base.getGridGeometry(); + final GridExtent fullExtent = geometry.getExtent(); + final GridExtent subExtent = image.getExtent(); + final MatrixSIS gridToCRS = MatrixSIS.castOrCopy(geometry.getGridToCRS(PixelInCell.CELL_CENTER) + .derivative(new DirectPositionView.Double(fullExtent.getPointOfInterest()))); + final double[] scales = new double[fullExtent.getDimension()]; + for (int i=0; i<scales.length; i++) { + scales[i] = fullExtent.getSize(i, false) / subExtent.getSize(i, false); + } + image.initReducedResolution(base, scales); + resolution = gridToCRS.multiply(scales); + for (int i=0; i<resolution.length; i++) { + resolution[i] = Math.abs(resolution[i]); + } + resolutions[level] = resolution; + } catch (TransformException e) { + throw new DataStoreReferencingException(e); + } catch (IOException e) { + throw levels[level].reader.store.errorIO(e); + } + return resolution; + } + + /** + * Returns the preferred resolutions (in units of CRS axes) for read operations in this data store. + */ + @Override + public List<double[]> getResolutions() throws DataStoreException { + final double[][] copy = new double[resolutions.length][]; + synchronized (getSynchronizationLock()) { + for (int i=0; i<copy.length; i++) { + copy[i] = resolution(i).clone(); + } + } + ArraysExt.reverse(copy); + return Arrays.asList(copy); + } + + /** + * Loads a subset of the grid coverage represented by this resource. + * + * @param domain desired grid extent and resolution, or {@code null} for reading the whole domain. + * @param range 0-based indices of sample dimensions to read, or {@code null} or an empty sequence for reading them all. + * @return the grid coverage for the specified domain and range. + * @throws DataStoreException if an error occurred while reading the grid coverage data. + */ + @Override + public GridCoverage read(final GridGeometry domain, final int... range) throws DataStoreException { + final double[] request = domain.getResolution(true); + int level = resolutions.length; + synchronized (getSynchronizationLock()) { +finer: while (--level > 0) { + final double[] resolution = resolution(level); + for (int i=0; i<request.length; i++) { + if (!(request[i] >= resolution[i])) { // Use `!` for catching NaN. + continue finer; + } + } + break; + } + final ImageFileDirectory image; + try { + image = getImageFileDirectory(level); + } catch (IOException e) { + throw levels[level].reader.store.errorIO(e); + } + image.setLoadingStrategy(getLoadingStrategy()); + return image.read(domain, range); + } + } +} diff --git a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/Reader.java b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/Reader.java index ef6223a..770836f 100644 --- a/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/Reader.java +++ b/storage/sis-geotiff/src/main/java/org/apache/sis/storage/geotiff/Reader.java @@ -26,6 +26,7 @@ import java.io.IOException; import java.nio.ByteOrder; import java.text.ParseException; import org.opengis.util.NameFactory; +import org.apache.sis.storage.GridCoverageResource; import org.apache.sis.storage.DataStoreException; import org.apache.sis.storage.DataStoreContentException; import org.apache.sis.internal.storage.io.ChannelDataInput; @@ -81,6 +82,12 @@ final class Reader extends GeoTIFF { final byte intSizeExpansion; /** + * The last <cite>Image File Directory</cite> (IFD) read, or {@code null} if none. + * This is used when we detected the end of a pyramid and the beginning of next one. + */ + private ImageFileDirectory lastIFD; + + /** * Offset (relative to the beginning of the TIFF file) of the next Image File Directory (IFD) * to read, or 0 if we have finished to read all of them. * @@ -98,10 +105,12 @@ final class Reader extends GeoTIFF { private final Set<Long> doneIFD; /** - * Positions of each <cite>Image File Directory</cite> (IFD) in this file. - * Those positions are fetched when first needed. + * Information about each (potentially pyramided) image in this file. + * Those objects are created when first needed. + * + * @see #getImage(int) */ - private final List<ImageFileDirectory> imageFileDirectories = new ArrayList<>(); + private final List<GridCoverageResource> images = new ArrayList<>(); /** * Entries having a value that can not be read immediately, but instead have a pointer @@ -229,90 +238,94 @@ final class Reader extends GeoTIFF { } /** - * Returns the <cite>Image File Directory</cite> (IFD) at the given index. - * If the IFD has already been read, then it is returned. - * Otherwise this method reads the IFD now and returns it. - * - * <p>The IFD consists of a 2 (classical) or 8 (BigTiff)-bytes count of the number of directory entries, - * followed by a sequence of 12-byte field entries, followed by a pointer to the next IFD (or 0 if none).</p> + * Returns the next <cite>Image File Directory</cite> (IFD), or {@code null} if we reached the last IFD. + * The IFD consists of a 2 (classical) or 8 (BigTiff)-bytes count of the number of directory entries, + * followed by a sequence of 12-byte field entries, followed by a pointer to the next IFD (or 0 if none). * - * @return the IFD if we found it, or {@code null} if there is no more IFD at the given index. + * @param index index of the (potentially pyramided) image, not counting reduced-resolution (overview) images. + * @return the IFD if we found it, or {@code null} if there is no more IFD. * @throws ArithmeticException if the pointer to a next IFD is too far. + * + * @see #getImage(int) */ - final ImageFileDirectory getImageFileDirectory(final int index) throws IOException, DataStoreException { - while (index >= imageFileDirectories.size()) { - if (nextIFD == 0) { - return null; - } - resolveDeferredEntries(null, nextIFD); - input.seek(Math.addExact(origin, nextIFD)); - nextIFD = 0; // Prevent trying other IFD if we fail to read this one. + private ImageFileDirectory getImageFileDirectory(final int index) throws IOException, DataStoreException { + if (nextIFD == 0) { + return null; + } + resolveDeferredEntries(null, nextIFD); + input.seek(Math.addExact(origin, nextIFD)); + nextIFD = 0; // Prevent trying other IFD if we fail to read this one. + /* + * Design note: we parse the Image File Directory entry now because even if we were + * not interrested in that IFD, we need to go anyway after its last record in order + * to get the pointer to the next IFD. + */ + final int offsetSize = Integer.BYTES << intSizeExpansion; + final ImageFileDirectory dir = new ImageFileDirectory(this, index); + for (long remaining = readUnsignedShort(); --remaining >= 0;) { /* - * Design note: we parse the Image File Directory entry now because even if we were - * not interrested in that IFD, we need to go anyway after its last record in order - * to get the pointer to the next IFD. + * Each entry in the Image File Directory has the following format: + * - The tag that identifies the field (see constants in the Tags class). + * - The field type (see constants inherited from the GeoTIFF class). + * - The number of values of the indicated type. + * - The value, or the file offset to the value elswhere in the file. */ - final int offsetSize = Integer.BYTES << intSizeExpansion; - final ImageFileDirectory dir = new ImageFileDirectory(this, index); - for (long remaining = readUnsignedShort(); --remaining >= 0;) { + final short tag = (short) input.readUnsignedShort(); + final Type type = Type.valueOf(input.readShort()); // May be null. + final long count = readUnsignedInt(); + final long size = (type != null) ? Math.multiplyExact(type.size, count) : 0; + if (size <= offsetSize) { /* - * Each entry in the Image File Directory has the following format: - * - The tag that identifies the field (see constants in the Tags class). - * - The field type (see constants inherited from the GeoTIFF class). - * - The number of values of the indicated type. - * - The value, or the file offset to the value elswhere in the file. + * If the value can fit inside the number of bytes given by `offsetSize`, then the value is + * stored directly at that location. This is the most common way TIFF tag values are stored. */ - final short tag = (short) input.readUnsignedShort(); - final Type type = Type.valueOf(input.readShort()); // May be null. - final long count = readUnsignedInt(); - final long size = (type != null) ? Math.multiplyExact(type.size, count) : 0; - if (size <= offsetSize) { - /* - * If the value can fit inside the number of bytes given by `offsetSize`, then the value is - * stored directly at that location. This is the most common way TIFF tag values are stored. - */ - final long position = input.getStreamPosition(); - if (size != 0) { - Object error; - try { - /* - * A size of zero means that we have an unknown type, in which case the TIFF specification - * recommends to ignore it (for allowing them to add new types in the future), or an entry - * without value (count = 0) - in principle illegal but we make this reader tolerant. - */ - error = dir.addEntry(tag, type, count); - } catch (ParseException | RuntimeException e) { - error = e; - } - if (error != null) { - warning(tag, error); - } + final long position = input.getStreamPosition(); + if (size != 0) { + Object error; + try { + /* + * A size of zero means that we have an unknown type, in which case the TIFF specification + * recommends to ignore it (for allowing them to add new types in the future), or an entry + * without value (count = 0) - in principle illegal but we make this reader tolerant. + */ + error = dir.addEntry(tag, type, count); + } catch (ParseException | RuntimeException e) { + error = e; + } + if (error != null) { + warning(tag, error); } - input.seek(position + offsetSize); // Usually just move the buffer position by a few bytes. - } else { - // Offset from beginning of TIFF file where the values are stored. - deferredEntries.add(new DeferredEntry(dir, tag, type, count, readUnsignedInt())); - dir.hasDeferredEntries = true; - deferredNeedsSort = true; } + input.seek(position + offsetSize); // Usually just move the buffer position by a few bytes. + } else { + // Offset from beginning of TIFF file where the values are stored. + deferredEntries.add(new DeferredEntry(dir, tag, type, count, readUnsignedInt())); + dir.hasDeferredEntries = true; + deferredNeedsSort = true; } - imageFileDirectories.add(dir); - readNextImageOffset(); // Zero if the IFD that we just read was the last one. } /* * At this point we got the requested IFD. But maybe some deferred entries need to be read. * The values of those entries may be anywhere in the TIFF file, in any order. Given that * seek operations in the input stream may be costly or even not possible, we try to read * all values in sequential order, including values of other IFD if there is some before - * our IFD of interest. + * our IFD of interest. This is the purpose of `resolveDeferredEntries(…)`. */ - final ImageFileDirectory dir = imageFileDirectories.get(index); + readNextImageOffset(); // Zero if the IFD that we just read was the last one. + return dir; + } + + /** + * Reads all entries that were deferred. + * + * @param dir the IFD for which to resolve deferred entries regardless stream position or {@code ignoreAfter} value. + */ + final void resolveDeferredEntries(final ImageFileDirectory dir) throws IOException, DataStoreException { if (dir.hasDeferredEntries) { resolveDeferredEntries(dir, Long.MAX_VALUE); dir.hasDeferredEntries = false; } dir.validateMandatoryTags(); - return dir; } /** @@ -361,6 +374,58 @@ final class Reader extends GeoTIFF { } /** + * Returns the potentially pyramided <cite>Image File Directories</cite> (IFDs) at the given index. + * If the pyramid has already been initialized, then it is returned. + * Otherwise this method initializes the pyramid now and returns it. + * + * <p>This method assumes that the first IFD is the full resolution image and all following IFDs having + * {@link ImageFileDirectory#isReducedResolution()} flag set are the same image at lower resolutions. + * This is the <cite>cloud optimized GeoTIFF</cite> convention.</p> + * + * @return the pyramid if we found it, or {@code null} if there is no more pyramid at the given index. + * @throws ArithmeticException if the pointer to a next IFD is too far. + */ + final GridCoverageResource getImage(final int index) throws IOException, DataStoreException { + while (index >= images.size()) { + int imageIndex = images.size(); + ImageFileDirectory fullResolution = lastIFD; + if (fullResolution == null) { + fullResolution = getImageFileDirectory(imageIndex); + if (fullResolution == null) { + return null; + } + } + lastIFD = null; // Clear now in case of error. + imageIndex++; // In case next image is full-resolution. + ImageFileDirectory image; + final List<ImageFileDirectory> overviews = new ArrayList<>(); + while ((image = getImageFileDirectory(imageIndex)) != null) { + if (image.isReducedResolution()) { + overviews.add(image); + } else { + lastIFD = image; + break; + } + } + /* + * All pyramid levels have been read. If there is only one level, + * use the image directly. Otherwise create the pyramid. + */ + if (overviews.isEmpty()) { + images.add(fullResolution); + } else { + overviews.add(0, fullResolution); + images.add(new MultiResolutionImage(overviews)); + } + } + final GridCoverageResource image = images.get(index); + if (image instanceof ImageFileDirectory) { + resolveDeferredEntries((ImageFileDirectory) image); + } + return image; + } + + /** * Logs a warning about a tag that can not be read, but does not interrupt the TIFF reading. * * @param tag the tag than can not be read.