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 e97299fd6765b136a4dae4d4c438fd2a7238a526 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Thu Sep 21 18:35:37 2023 +0200 Initial GeoKeys support. https://issues.apache.org/jira/browse/SIS-589 --- .../apache/sis/referencing/IdentifiedObjects.java | 2 + .../org/apache/sis/storage/geotiff/GeoKeys.java | 13 + .../apache/sis/storage/geotiff/GeoKeysWriter.java | 790 +++++++++++++++++++++ .../org/apache/sis/storage/geotiff/Writer.java | 53 +- .../sis/storage/geotiff/internal/Resources.java | 20 + .../storage/geotiff/internal/Resources.properties | 4 + .../geotiff/internal/Resources_fr.properties | 4 + .../apache/sis/storage/geotiff/GeoKeysTest.java | 10 + .../org/apache/sis/storage/geotiff/WriterTest.java | 50 +- .../apache/sis/storage/base/MetadataBuilder.java | 6 +- .../apache/sis/storage/base/MetadataFetcher.java | 56 ++ .../main/org/apache/sis/util/resources/Errors.java | 5 + .../apache/sis/util/resources/Errors.properties | 1 + .../apache/sis/util/resources/Errors_fr.properties | 1 + 14 files changed, 994 insertions(+), 21 deletions(-) diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/IdentifiedObjects.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/IdentifiedObjects.java index d4b50835d8..db3442e7c3 100644 --- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/IdentifiedObjects.java +++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/IdentifiedObjects.java @@ -35,6 +35,7 @@ import org.opengis.referencing.operation.ConcatenatedOperation; import org.apache.sis.util.Static; import org.apache.sis.util.CharSequences; import org.apache.sis.util.ArgumentChecks; +import org.apache.sis.util.OptionalCandidate; import org.apache.sis.util.logging.Logging; import org.apache.sis.xml.IdentifierSpace; import org.apache.sis.util.internal.Strings; @@ -556,6 +557,7 @@ public final class IdentifiedObjects extends Static { * * @since 0.7 */ + @OptionalCandidate public static Integer lookupEPSG(final IdentifiedObject object) throws FactoryException { Integer code = null; if (object != null) { diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeys.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeys.java index 2740943ba1..2762793adc 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeys.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeys.java @@ -105,6 +105,19 @@ final class GeoKeys { /** For user-defined CRS. */ public static final short VerticalDatum = 4098; /** For vertical axis. */ public static final short VerticalUnits = 4099; + /** + * Number of keys. Because keys cannot be repeated, this is the maximal + * number of entries that {@link GeoKeysWriter#keyDirectory} can contain. + * This value is verified by the {@code GeoKeysTest.verifyNumKeys()}. + */ + static final int NUM_KEYS = 46; + + /** + * Number of parameters that are of type {@code double}. + * This is the maximal length of {@link GeoKeysWriter#doubleParams}. + */ + static final int NUM_DOUBLES = 25; + /** * Returns the name of the given key. Implementation of this method is inefficient, * but it should rarely be invoked (mostly for formatting error messages). diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeysWriter.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeysWriter.java new file mode 100644 index 0000000000..38f00f0067 --- /dev/null +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeysWriter.java @@ -0,0 +1,790 @@ +/* + * 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.EnumMap; +import javax.measure.Unit; +import javax.measure.quantity.Angle; +import javax.measure.quantity.Length; +import javax.measure.UnitConverter; +import javax.measure.IncommensurableException; +import static javax.imageio.plugins.tiff.GeoTIFFTagSet.TAG_GEO_ASCII_PARAMS; +import static javax.imageio.plugins.tiff.GeoTIFFTagSet.TAG_GEO_DOUBLE_PARAMS; +import org.opengis.util.FactoryException; +import org.opengis.metadata.Identifier; +import org.opengis.metadata.spatial.CellGeometry; +import org.opengis.referencing.IdentifiedObject; +import org.opengis.referencing.crs.CoordinateReferenceSystem; +import org.opengis.referencing.crs.GeodeticCRS; +import org.opengis.referencing.crs.ProjectedCRS; +import org.opengis.referencing.crs.VerticalCRS; +import org.opengis.referencing.cs.AxisDirection; +import org.opengis.referencing.cs.CoordinateSystem; +import org.opengis.referencing.cs.CartesianCS; +import org.opengis.referencing.cs.EllipsoidalCS; +import org.opengis.referencing.datum.Ellipsoid; +import org.opengis.referencing.datum.PrimeMeridian; +import org.opengis.referencing.datum.GeodeticDatum; +import org.opengis.referencing.datum.VerticalDatum; +import org.opengis.referencing.datum.PixelInCell; +import org.opengis.referencing.operation.Conversion; +import org.opengis.referencing.operation.Matrix; +import org.opengis.parameter.GeneralParameterValue; +import org.opengis.parameter.ParameterValue; +import org.apache.sis.measure.Units; +import org.apache.sis.util.ArraysExt; +import org.apache.sis.util.resources.Errors; +import org.apache.sis.util.internal.Strings; +import org.apache.sis.util.internal.CollectionsExt; +import org.apache.sis.referencing.CRS; +import org.apache.sis.referencing.IdentifiedObjects; +import org.apache.sis.referencing.cs.CoordinateSystems; +import org.apache.sis.referencing.operation.matrix.Matrices; +import org.apache.sis.referencing.operation.transform.MathTransforms; +import org.apache.sis.referencing.util.ReferencingUtilities; +import org.apache.sis.referencing.util.WKTKeywords; +import org.apache.sis.coverage.grid.GridGeometry; +import org.apache.sis.coverage.grid.IncompleteGridGeometryException; +import org.apache.sis.storage.base.MetadataFetcher; +import org.apache.sis.storage.geotiff.internal.Resources; +import org.apache.sis.metadata.iso.citation.Citations; + + +/** + * Helper class for writing GeoKeys. + * This class decomposes a CRS into entries written by calls to {@code writeShort(…)}, {@code writeDouble(…)} + * or {@code writeString(…)} methods. The order in which those methods are invoked matter, because the GeoTIFF + * specification requires that keys are sorted in increasing order. We do not sort them after writing. + * + * @author Martin Desruisseaux (Geomatys) + */ +final class GeoKeysWriter { + /** + * Size of the model transformation matrix, in number of rows and columns. + * This size is fixed by the GeoTIFF specification. + */ + private static final int MATRIX_SIZE = 4; + + /** + * The store for which we are writing GeoTIFF keys. + * Used for logging warnings. + */ + private final GeoTiffStore store; + + /** + * Overall configuration of the GeoTIFF file, or {@code null} if none. + * This is the value to store in {@link GeoKeys#Citation}. + */ + private String citation; + + /** + * The coordinate reference system of the grid geometry, or {@code null} if none. + * This CRS may contain more dimensions than the 3 dimensions allowed by GeoTIFF. + * Axis order and axis directions may be different than the (east, north, up) directions mandated by GeoTIFF. + */ + private CoordinateReferenceSystem fullCRS; + + /** + * Whether the coordinate system has a vertical component. + */ + private boolean hasVerticalAxis; + + /** + * The conversion from grid coordinates to full CRS, which determines the model transformation. + * This conversion may operate on more dimensions than the three dimensions mandated by GeoTIFF. + * Furthermore the output may need to be reordered for the (east, north, up) axis order mandated by GeoTIFF. + * + * @see #modelTransformation() + */ + private Matrix gridToCRS; + + /** + * Whether the raster model is "point" or "area". + * The default is area ({@code false}). + */ + private boolean isPoint; + + /** + * Units of measurement found by the analysis of coordinate system axes. + * Should be filled as soon as possible because it determines also the + * units of measurement to use for encoding map projection parameters. + */ + private final EnumMap<UnitKey, Unit<?>> units; + + /** + * The key directory, including the header. + * Each entry is a record of {@value GeoKeysLoader#ENTRY_LENGTH} values. + * The first record is a header of the same length. + * + * @see #keyCount + * @see #keyDirectory() + */ + private final short[] keyDirectory; + + /** + * Number of valid elements in {@link #keyDirectory}, not counting the header. + */ + private int keyCount; + + /** + * Parameters to encode as IEEE-754 floating point values. + * + * @see #doubleCount + * @see #doubleParams() + */ + private final double[] doubleParams; + + /** + * Number of valid elements in {@link #doubleParams}. + */ + private int doubleCount; + + /** + * Parameters to encode as ASCII character strings. + * Strings are separated by the {@value GeoKeysLoader#SEPARATOR} character. + * + * @see #asciiParams() + */ + private final StringBuilder asciiParams; + + /** + * If multiple names are packed in a single citation GeoKey, the citation key of the main object. + * This is a sub-encoding applied inside {@link #asciiParams} for the citation. Example: + * + * <pre>GCS Name=Moon 2000|Datum=D_Moon_2000|Ellipsoid=Moon_2000_IAU_IAG|Primem=Reference_Meridian|AUnits=Decimal_Degree|</pre> + * + * Above sub-encoding is applied only if necessary. In such case, this field is the first key to prepend. + * Currently the only accepted value is: "GCS Name". + */ + private String citationMainKey; + + /** + * Index in the {@link #keyDirectory} array where the length (in number of characters) of current citation is stored. + * The {@code keyDirectory[citationLengthIndex]} value is the number of characters, excluding the trailing separator. + * The {@code keyDirectory[citationLengthIndex+1]} value is the offset where the citation starts. + * This information is used for modifying in-place the ASCII entry of a citation for inserting more names. + */ + private int citationLengthIndex; + + /** + * Prepares information for writing GeoTIFF tags for the given grid geometry. + * Caller shall invoke {@link #write(GridGeometry, MetadataFetcher)} exactly once after construction. + * + * @param store the store for which to write GeoTIFF keys. + */ + GeoKeysWriter(final GeoTiffStore store) { + this.store = store; + units = new EnumMap<>(UnitKey.class); + asciiParams = new StringBuilder(100); + doubleParams = new double[GeoKeys.NUM_DOUBLES]; + keyDirectory = new short[(GeoKeys.NUM_KEYS + 1) * GeoKeysLoader.ENTRY_LENGTH]; + keyDirectory[0] = 1; // Directory version. + keyDirectory[1] = 1; // Revision major number. We implement GeoTIFF 1.1. + keyDirectory[2] = 1; // Revision minor number. We implement GeoTIFF 1.1. + } + + /** + * Writes GeoTIFF keys for the given grid geometry. + * This method should be invoked exactly once. + * + * @param store the store for which to write GeoTIFF keys. + * @param grid grid geometry of the image to write. + * @param metadata overall configuration information. + * @throws FactoryException if an error occurred while fetching the EPSG code. + * @throws ArithmeticException if a short value cannot be stored as an unsigned 16 bits integer. + * @throws IncommensurableException if a measure uses an unexpected unit of measurement. + * @throws IncompleteGridGeometryException if the grid geometry is incomplete. + */ + final void write(final GridGeometry grid, final MetadataFetcher<?> metadata) + throws FactoryException, IncommensurableException + { + citation = CollectionsExt.first(metadata.transformationDimension); + isPoint = CellGeometry.POINT.equals(CollectionsExt.first(metadata.cellGeometry)); + gridToCRS = MathTransforms.getMatrix(grid.getGridToCRS(isPoint ? PixelInCell.CELL_CENTER : PixelInCell.CELL_CORNER)); + if (gridToCRS == null) { + warning(resources().getString(Resources.Keys.CanNotEncodeNonLinearModel), null); + } + if (grid.isDefined(GridGeometry.CRS)) { + fullCRS = grid.getCoordinateReferenceSystem(); + final CoordinateReferenceSystem crs = CRS.getHorizontalComponent(fullCRS); + if ((crs instanceof ProjectedCRS && writeCRS((ProjectedCRS) crs)) || + (crs instanceof GeodeticCRS && writeCRS((GeodeticCRS) crs, false))) + { + writeCRS(CRS.getVerticalComponent(fullCRS, true)); + } else { + unsupportedType(fullCRS); + writeModelType(GeoCodes.userDefined); + } + } else { + writeModelType(GeoCodes.undefined); + } + } + + /** + * Writes the first keys (model type, raster type, citation). + * This method shall be the first write operation, before to write any other keys. + * + * @param type value of {@link GeoKeys#ModelType}. + */ + private void writeModelType(final short type) { + writeShort(GeoKeys.ModelType, type); + writeShort(GeoKeys.RasterType, isPoint ? GeoCodes.RasterPixelIsPoint : GeoCodes.RasterPixelIsArea); + if (citation != null) { + writeString(GeoKeys.Citation, citation); + citation = null; + } + } + + /** + * Writes the vertical component of the CRS. + * The horizontal component must have been written before this method is invoked. + * + * @param crs the CRS to write, or {@code null} if none. + * @throws FactoryException if an error occurred while fetching an EPSG code. + */ + private void writeCRS(final VerticalCRS crs) throws FactoryException { + if (crs != null) { + hasVerticalAxis = true; + if (writeEPSG(GeoKeys.Vertical, crs)) { + writeName(GeoKeys.VerticalCitation, null, crs); + addUnits(UnitKey.VERTICAL, crs.getCoordinateSystem()); + final VerticalDatum datum = crs.getDatum(); + if (writeEPSG(GeoKeys.VerticalDatum, datum)) { + /* + * OGC requirement 25.5 said "VerticalCitationGeoKey SHALL be populated." + * But how? Using the same multiple-names convention as for geodetic CRS? + * + * https://github.com/opengeospatial/geotiff/issues/59 + */ + } + writeUnit(UnitKey.VERTICAL); + } + } + } + + /** + * Writes entries for a geographic or geocentric CRS. + * The CRS type is inferred from the coordinate system type. + * This method may be invoked for writing the base CRS of a projected CRS. + * + * @param crs the CRS to write. + * @param isBaseCRS whether to write the base CRS of a projected CRS. + * @return whether this method has been able to write the CRS. + * @throws FactoryException if an error occurred while fetching an EPSG code. + * @throws IncommensurableException if a measure uses an unexpected unit of measurement. + */ + private boolean writeCRS(final GeodeticCRS crs, final boolean isBaseCRS) throws FactoryException, IncommensurableException { + final short type; + final CoordinateSystem cs = crs.getCoordinateSystem(); + addUnits(UnitKey.ANGULAR, cs); + if (cs instanceof EllipsoidalCS) { + type = GeoCodes.ModelTypeGeographic; + } else if (isBaseCRS) { + warning(resources().getString(Resources.Keys.CanNotEncodeNonGeographicBase), null); + return false; + } else if (cs instanceof CartesianCS) { + type = GeoCodes.ModelTypeGeocentric; + } else { + unsupportedType(cs); + return false; + } + /* + * Start writing GeoTIFF keys for the geodetic CRS, potentially followed by datum, prime meridian and ellipsoid + * in that order. The order matter because GeoTIFF specification requires keys to be sorted in increasing order. + * A difficulty is that units of measurement are between prime meridian and ellipsoid, and the angular unit is + * needed for projected CRS too. + */ + writeModelType(isBaseCRS ? GeoCodes.ModelTypeProjected : type); + if (writeEPSG(GeoKeys.GeodeticCRS, crs)) { + writeName(GeoKeys.GeodeticCitation, "GCS Name", crs); + final GeodeticDatum datum = crs.getDatum(); + if (writeEPSG(GeoKeys.GeodeticDatum, datum)) { + appendName(WKTKeywords.Datum, datum); + final PrimeMeridian primem = datum.getPrimeMeridian(); + final double longitude; + if (writeEPSG(GeoKeys.PrimeMeridian, primem)) { + appendName(WKTKeywords.PrimeM, datum); + longitude = primem.getGreenwichLongitude(); + } else { + longitude = 0; // Means "do not write prime meridian". + } + final Ellipsoid ellipsoid = datum.getEllipsoid(); + final Unit<Length> axisUnit = ellipsoid.getAxisUnit(); + final Unit<?> linearUnit = units.putIfAbsent(UnitKey.LINEAR, axisUnit); + final UnitConverter toLinear = axisUnit.getConverterToAny(linearUnit != null ? linearUnit : axisUnit); + writeUnit(UnitKey.LINEAR); // Must be after the `units` map have been updated. + writeUnit(UnitKey.ANGULAR); + if (writeEPSG(GeoKeys.Ellipsoid, ellipsoid)) { + appendName(WKTKeywords.Ellipsoid, ellipsoid); + writeDouble(GeoKeys.SemiMajorAxis, toLinear.convert(ellipsoid.getSemiMajorAxis())); + if (ellipsoid.isSphere() || !ellipsoid.isIvfDefinitive()) { + writeDouble(GeoKeys.SemiMinorAxis, toLinear.convert(ellipsoid.getSemiMinorAxis())); + } else { + writeDouble(GeoKeys.InvFlattening, ellipsoid.getInverseFlattening()); + } + } + if (longitude != 0) { + Unit<Angle> unit = primem.getAngularUnit(); + UnitConverter c = unit.getConverterToAny(units.getOrDefault(UnitKey.ANGULAR, Units.DEGREE)); + writeDouble(GeoKeys.PrimeMeridianLongitude, c.convert(longitude)); + } + } + } else if (isBaseCRS) { + writeUnit(UnitKey.ANGULAR); // Map projection parameters may need this unit. + } + return true; + } + + /** + * Writes entries for a projected CRS. + * If the CRS is user-specified, then this method writes the geodetic CRS first. + * + * @return whether this method has been able to write the CRS. + * @throws FactoryException if an error occurred while fetching an EPSG or GeoTIFF code. + * @throws IncommensurableException if a measure uses an unexpected unit of measurement. + */ + private boolean writeCRS(final ProjectedCRS crs) throws FactoryException, IncommensurableException { + if (!writeCRS(crs.getBaseCRS(), true)) { + return false; + } + if (writeEPSG(GeoKeys.ProjectedCRS, crs)) { + writeName(GeoKeys.ProjectedCitation, null, crs); + addUnits(UnitKey.PROJECTED, crs.getCoordinateSystem()); + final Conversion projection = crs.getConversionFromBase(); + if (writeEPSG(GeoKeys.Projection, projection)) { + final var method = projection.getMethod(); + final short projCode = getGeoCode(method); + writeShort(GeoKeys.ProjMethod, projCode); + writeUnit(UnitKey.PROJECTED); + switch (projCode) { + case GeoCodes.undefined: missingValue(GeoKeys.ProjMethod); return true; + case GeoCodes.userDefined: cannotEncode(0, name(method), null); break; + /* + * TODO: GeoTIFF requirement 27.4 said that ProjectedCitationGeoKey shall be provided, + * But how? Using the same multiple-names convention ("GCS Name") as for geodetic CRS? + * + * https://github.com/opengeospatial/geotiff/issues/59 + */ + } + } + for (final GeneralParameterValue p : projection.getParameterValues().values()) { + RuntimeException cause = null; + final var descriptor = p.getDescriptor(); + if (p instanceof ParameterValue<?>) { + final short key = getGeoCode(descriptor); + if (key != GeoCodes.undefined && key != GeoCodes.userDefined) { + final var pv = (ParameterValue<?>) p; + final UnitKey type = UnitKey.ofProjectionParameter(key); + if (type == UnitKey.LINEAR) { + continue; // Skip the "cannot encode" warning. + } + if (type != UnitKey.NULL) try { + final Unit<?> unit = units.getOrDefault(type, type.defaultUnit()); + writeDouble(key, (unit != null) ? pv.doubleValue(unit) : pv.doubleValue()); + continue; + } catch (IllegalArgumentException | IllegalStateException e) { + cause = e; + } + } + } + cannotEncode(1, name(descriptor), cause); + } + } + return true; + } + + /** + * Remembers the units of measurement found in all coordinate system axes. + * The units are stored in the {@link #units} map. + * + * @param main the main kind of units expected in the coordinate system. + * @param cs the coordinate system to analyze. + */ + private void addUnits(final UnitKey main, final CoordinateSystem cs) { + for (int i = cs.getDimension(); --i >= 0;) { + final Unit<?> unit = cs.getAxis(i).getUnit(); + final UnitKey type = main.validate(unit); + if (type != null) { + final Unit<?> previous = units.putIfAbsent(type, unit); + if (previous != null && !previous.equals(unit)) { + warning(store.errors().getString(Errors.Keys.HeterogynousUnitsIn_1, name(cs)), null); + } + } else { + cannotEncode(2, unit.toString(), null); + } + } + } + + /** + * Writes the entries for the specified unit of measurement. + * This method should be invoked only once per unit key. + * + * @param key identification of the unit to write. + */ + private void writeUnit(final UnitKey key) { + final Unit<?> unit = units.get(key); + if (unit != null) { + final short epsg = toShortEPSG(Units.getEpsgCode(unit, key.isAxis)); + if (epsg != GeoCodes.userDefined) { + writeShort(key.codeKey, epsg); + } else if (key.scaleKey != 0) { + writeShort(key.codeKey, epsg); + writeDouble(key.scaleKey, Units.toStandardUnit(unit)); + } else { + cannotEncode(2, unit.toString(), null); + } + } + } + + /** + * Writes the name of the specified object. + * + * @param key the numeric identifier of the GeoTIFF key. + * @param type type of object for which to write the name, or {@code null} for no multiple-names citation. + * @param object the object for which to write the name. + */ + private void writeName(final short key, final String type, final IdentifiedObject object) { + String name = IdentifiedObjects.getName(object, null); + if (name == null) { + name = "Unnamed"; + } + writeString(key, name); + citationMainKey = type; + citationLengthIndex = keyCount * GeoKeysLoader.ENTRY_LENGTH + 2; // Length is the field #2. + } + + /** + * Writes the name of the specified object using the "multi-names in single citation" convention. + * The {@link #writeName(short, String, IdentifiedObject)} method must have been invoked for the + * main object before this method call. + * + * @param type type of object for which to write the name. + * @param object the object for which to write the name. + */ + private void appendName(final String type, final IdentifiedObject object) { + final String name = IdentifiedObjects.getName(object, null); + if (name != null) { + int i = citationLengthIndex; + int offset = Short.toUnsignedInt(keyDirectory[i+1]); + int length = Short.toUnsignedInt(keyDirectory[i]); + int start = length; + if (citationMainKey != null) { + final String value = citationMainKey + '='; + asciiParams.insert(offset, value); + length += value.length(); + citationMainKey = null; + } + final String value = GeoKeysLoader.SEPARATOR + type + '=' + name; + asciiParams.insert(offset + length, value); + keyDirectory[i] = toShort(length += value.length()); + /* + * After we inserted the name, adjust the offsets of all ASCII entries written after the citation. + * Note that in the following loop, (i < limit) must be tested before increment because the limit + * is inclusive. This loop will do nothing with GeoTIFF 1.1 because there is no other ASCII entry + * after citation, but we keep it in case a future GeoTIFF version adds more ASCII entries. + */ + final int shift = length - start; + final int limit = keyCount * GeoKeysLoader.ENTRY_LENGTH; // Inclusive. + i++; // Offset is the field after length. + while (i < limit) { + i += GeoKeysLoader.ENTRY_LENGTH; // Really after (i < limit) test. + if (keyDirectory[i-2] == (short) TAG_GEO_ASCII_PARAMS) { + offset = Short.toUnsignedInt(keyDirectory[i]); + keyDirectory[i] = toShort(offset + shift); + } + } + } + } + + /** + * Fetches the GeoTIFF code of the given object. If {@code null}, returns {@link GeoCodes#undefined}. + * If the object has no GeTIFF identifier, returns {@value GeoCodes#userDefined}. + * + * @param object the object for which to get the GeoTIFF code. + * @return the GeoTIFF code, or {@link GeoCodes#undefined} or {@link GeoCodes#userDefined} if none. + * @throws FactoryException if an error occurred while fetching the GeoTIFF code. + */ + private short getGeoCode(final IdentifiedObject object) throws FactoryException { + if (object == null) { + return GeoCodes.undefined; + } + final Identifier id = IdentifiedObjects.getIdentifier(object, Citations.GEOTIFF); + if (id != null) try { + return Short.parseShort(id.getCode()); + } catch (NumberFormatException e) { + warning(store.errors().getString(Errors.Keys.CanNotParse_1, IdentifiedObjects.toString(id)), e); + } + return GeoCodes.userDefined; + } + + /** + * Writes the EPSG code of the given object, or {@value GeoCodes#userDefined} if none. + * Returns whether the caller should write user-defined object in replacement or in addition to EPSG code. + * + * @param key the numeric identifier of the GeoTIFF key. + * @param object the object for which to get the EPSG code. + * @return whether the caller should write user-defined object. + * @throws FactoryException if an error occurred while fetching the EPSG code. + */ + private boolean writeEPSG(final short key, final IdentifiedObject object) throws FactoryException { + if (object == null) { + writeShort(key, GeoCodes.undefined); + missingValue(key); + return false; + } + /* + * Note `lookupEPSG(…)` will return a value only if the axes have the same order and units. + * We could ignore axis order because GeoTIFF specification fixes it to (east, north, up), + * but we shall not ignore axis units. The `IdentifiedObjectFinder` API does not currently + * allow ignoring only axis order, so we fallback on strict equality (ignoring metadata). + * This is not necessarily a bad thing, because there is a possibility that future GeoTIFF + * specifications become stricter, so we are already "strict" regarding usages of EPSG codes. + */ + final short epsg = toShortEPSG(IdentifiedObjects.lookupEPSG(object)); + writeShort(key, epsg); + return (epsg == GeoCodes.userDefined); + } + + /** + * Returns an optional EPSG code as a short code that can be stored in a GeoTIFF key. + * + * @param epsg the optional EPSG code. + * @return the code as a short integer, or {@link GeoCodes#userDefined} if none. + * + * @see #toShort(int) + */ + private static short toShortEPSG(final Integer epsg) { + if (epsg != null) { + final int c = epsg; + if (c >= 1024 && c <= 32766) { // This range is defined by the GeoTIFF specification. + return (short) c; + } + } + return GeoCodes.userDefined; + } + + /** + * Appends an entry for a 16 bits integer value. This method uses a TIFF tag location of 0, + * which implies that value is {@code SHORT}, and is contained in the "ValueOffset" entry + * + * @param key the numeric identifier of the GeoTIFF key. + * @param value the value to store. + */ + private void writeShort(final short key, final short value) { + int i = ++keyCount * GeoKeysLoader.ENTRY_LENGTH; + keyDirectory[i++] = key; // Key identifier. + keyDirectory[++i] = 1; // Number of values in this key. + keyDirectory[++i] = value; // Value offset. In this particular case, contains directly the value. + } + + /** + * Appends an entry for a floating point value. + * + * @param key the numeric identifier of the Key. + * @param value the value to store. + */ + private void writeDouble(final short key, final double value) { + int i = ++keyCount * GeoKeysLoader.ENTRY_LENGTH; + keyDirectory[i++] = key; // Key identifier. + keyDirectory[i++] = (short) TAG_GEO_DOUBLE_PARAMS; // TIFF tag location. + keyDirectory[i++] = 1; // Number of values in this key. + keyDirectory[i ] = toShort(doubleCount); + doubleParams[doubleCount++] = value; + } + + /** + * Appends an entry for a character string. + * + * @param key the numeric identifier of the GeoTIFF key. + * @param value the value to store. + */ + private void writeString(final short key, final String value) { + int i = ++keyCount * GeoKeysLoader.ENTRY_LENGTH; + keyDirectory[i++] = key; // Key identifier. + keyDirectory[i++] = (short) TAG_GEO_ASCII_PARAMS; // TIFF tag location. + keyDirectory[i++] = toShort(value.length()); // Number of values in this key. + keyDirectory[i ] = toShort(asciiParams.length()); // Offset of the first character. + asciiParams.append(value).append(GeoKeysLoader.SEPARATOR); + } + + /** + * Ensures that the given value can be represented as an unsigned 16 bits integer. + * + * @param value the value to cast to an unsigned short. + * @return the value as an unsigned short. + * @throws ArithmeticException if the given value can not be stored as an unsigned 16 bits integer. + * + * @see #toShortEPSG(Integer) + */ + private static short toShort(final int value) { + if ((value & ~0xFFFF) == 0) { + return (short) value; + } + throw new ArithmeticException(Errors.format(Errors.Keys.IntegerOverflow_1, Short.SIZE)); + } + + /** + * {@return the values to write in the "GeoTIFF keys directory" tag}. + */ + final short[] keyDirectory() { + if (keyCount == 0) return null; + keyDirectory[GeoKeysLoader.ENTRY_LENGTH - 1] = (short) keyCount; + return ArraysExt.resize(keyDirectory, (keyCount + 1) * GeoKeysLoader.ENTRY_LENGTH); + } + + /** + * {@return the values to write in the "GeoTIFF double-precision parameters" tag}. + */ + final double[] doubleParams() { + if (doubleCount == 0) return null; + return ArraysExt.resize(doubleParams, doubleCount); + } + + /** + * {@return the values to write in the "GeoTIFF ASCII strings" tag}. + */ + final List<String> asciiParams() { + if (asciiParams.length() == 0) return null; // TODO: replace by isEmpty() with JDK15. + return List.of(asciiParams.toString()); + } + + /** + * Returns the coefficients of the affine transform, or {@code null} if none. + * Array length is fixed to 16 elements, for a 4×4 matrix in row-major order. + * Axis order is fixed to (longitude, latitude, height). + */ + final double[] modelTransformation() { + if (gridToCRS == null) { + return null; + } + /* + * The CRS stored in GeoTIFF files have axis directions fixed to (east, north, up). + * If the CRS of the grid geometry has different axis order, we need to adjust the + * "grid to CRS" transform. + */ + if (fullCRS != null) { + final AxisDirection[] source = CoordinateSystems.getAxisDirections(fullCRS.getCoordinateSystem()); + final AxisDirection[] target = new AxisDirection[hasVerticalAxis ? 3 : 2]; + target[0] = AxisDirection.EAST; + target[1] = AxisDirection.NORTH; + if (hasVerticalAxis) { + target[2] = AxisDirection.UP; + } + gridToCRS = Matrices.createTransform(source, target).multiply(gridToCRS); + fullCRS = null; // For avoiding to do the multiplication again. + } + /* + * Copy matrix coefficients. This matrix size is always 4×4, no matter the size of the `gridToCRS` matrix. + * So we cannot invoke `MatrixSIS.getElements()`. + */ + final double[] cf = new double[MATRIX_SIZE * MATRIX_SIZE]; + final int lastRow = gridToCRS.getNumRow() - 1; + final int lastCol = gridToCRS.getNumCol() - 1; + final int maxRow = Math.min(lastRow, MATRIX_SIZE-1); + int offset = 0; + for (int row = 0; row < maxRow; row++) { + copyRow(gridToCRS, row, lastCol, cf, offset); + offset += MATRIX_SIZE; + } + copyRow(gridToCRS, lastRow, lastCol, cf, MATRIX_SIZE * (MATRIX_SIZE - 1)); + return cf; + } + + /** + * Copies a matrix row into the model transformation array. + * + * @param gridToCRS the source of model transformation coefficients. + * @param row row of the matrix to copy. + * @param lastCol value of {@code gridToCRS.getNumCol() - 1}. + * @param target where to write the coefficients. + * @param offset index of the first element to write in the destination array. + */ + private static void copyRow(final Matrix gridToCRS, final int row, final int lastCol, final double[] target, final int offset) { + target[offset + (MATRIX_SIZE - 1)] = gridToCRS.getElement(row, lastCol); + for (int i = Math.min(lastCol, MATRIX_SIZE-1); --i >= 0;) { + target[offset + i] = gridToCRS.getElement(row, i); + } + } + + /** + * Returns the name of the given object. Used for formatting error messages. + * + * @param object the object for which to get a name to insert in error message. + * @return the object name. + */ + private String name(final IdentifiedObject object) { + return IdentifiedObjects.getDisplayName(object, store.getLocale()); + } + + /** + * {@return the resources in the current locale}. + */ + private Resources resources() { + return Resources.forLocale(store.getLocale()); + } + + /** + * Logs a warning saying that no value is associated to the given key. + * + * @param key the GeoKey for which we found no value. + */ + private void missingValue(final short key) { + warning(resources().getString(Resources.Keys.MissingGeoValue_1, GeoKeys.name(key)), null); + } + + /** + * Logs a warning saying that the given object cannot be encoded becasuse of its type. + * + * @param object object that cannot be encoded. + */ + private void unsupportedType(final IdentifiedObject object) { + warning(resources().getString(Resources.Keys.CanNotEncodeObjectType_1, ReferencingUtilities.getInterface(object)), null); + } + + /** + * Logs a warning saying that an object of the given name cannot be encoded. + * + * @param type object type: 0 = operation method, 1 = parameter, 2 = unit of measurement. + * @param name name of the object that cannot be encoded. + * @param cause the reason why a warning occurred, or {@code null} if none. + */ + private void cannotEncode(final int type, final String name, final Exception cause) { + warning(resources().getString(Resources.Keys.CanNotEncodeNamedObject_2, type, name), cause); + } + + /** + * Reports a warning that occurred while analyzing the CRS. + * This warning may prevent readers to reconstruct the CRS correctly. + * + * @param message the warning message. + * @param cause the reason why a warning occurred, or {@code null} if none. + */ + private void warning(final String message, final Exception cause) { + store.listeners().warning(message, cause); + } + + /** + * Returns a string representation for debugging purpose. + * + * @return a string representation of this keys writer. + */ + @Override + public String toString() { + return Strings.toString(getClass(), "citation", citation); + } +} 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 f86f7bfb61..360504e823 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 @@ -32,10 +32,14 @@ import java.awt.image.SampleModel; import java.awt.image.BandedSampleModel; import java.awt.image.IndexColorModel; import javax.imageio.plugins.tiff.TIFFTag; +import javax.measure.IncommensurableException; +import org.opengis.util.FactoryException; import org.opengis.metadata.Metadata; import org.apache.sis.image.DataType; import org.apache.sis.image.ImageProcessor; +import org.apache.sis.coverage.grid.GridGeometry; import org.apache.sis.storage.DataStoreException; +import org.apache.sis.storage.DataStoreReferencingException; import org.apache.sis.storage.base.MetadataFetcher; import org.apache.sis.io.stream.ChannelDataOutput; import org.apache.sis.io.stream.UpdatableWrite; @@ -46,6 +50,7 @@ import org.apache.sis.util.ArraysExt; import org.apache.sis.math.Fraction; import static javax.imageio.plugins.tiff.BaselineTIFFTagSet.*; +import static javax.imageio.plugins.tiff.GeoTIFFTagSet.*; /** @@ -205,17 +210,18 @@ final class Writer extends GeoTIFF implements Flushable { * It is caller responsibility to append image overviews if a pyramid is wanted. * * @param image the image to encode. + * @param grid mapping from pixel coordinates to "real world" coordinates, or {@code null} if none. * @param metadata title, author and other information, or {@code null} if none. * @throws IOException if an error occurred while writing to the output. * @throws DataStoreException if the given {@code image} has a property * which is not supported by TIFF specification or by this writer. */ - final void append(final RenderedImage image, final Metadata metadata) + final void append(final RenderedImage image, final GridGeometry grid, final Metadata metadata) throws IOException, DataStoreException { final TileMatrixWriter tiles; try { - tiles = writeImageFileDirectory(new ReformattedImage(this, image), metadata, false); + tiles = writeImageFileDirectory(new ReformattedImage(this, image), grid, metadata, false); } finally { largeTagData.clear(); // For making sure that there is no memory retention. } @@ -230,6 +236,7 @@ final class Writer extends GeoTIFF implements Flushable { * This separation makes possible to write directories in any order compared to pixel data. * * @param image the image for which to write the IFD. + * @param grid mapping from pixel coordinates to "real world" coordinates, or {@code null} if none. * @param metadata title, author and other information, or {@code null} if none. * @param oveverview whether the image is an overview of another image. * @return handler for writing offsets and lengths of the tiles to write. @@ -237,7 +244,7 @@ final class Writer extends GeoTIFF implements Flushable { * @throws DataStoreException if the given {@code image} has a property * which is not supported by TIFF specification or by this writer. */ - private TileMatrixWriter writeImageFileDirectory(final ReformattedImage image, final Metadata metadata, + private TileMatrixWriter writeImageFileDirectory(final ReformattedImage image, final GridGeometry grid, final Metadata metadata, final boolean overview) throws IOException, DataStoreException { /* @@ -277,13 +284,20 @@ final class Writer extends GeoTIFF implements Flushable { * Metadata (optional) and GeoTIFF. They are managed by separated classes. */ final double[][] statistics = image.statistics(numBands); - final int[][] shortStats = toShorts(statistics, sampleFormat); + final short[][] shortStats = toShorts(statistics, sampleFormat); final MetadataFetcher<String> mf = new MetadataFetcher<>(store.dataLocale) { @Override protected String parseDate(final Date date) { return getDateFormat().format(date); } }; mf.accept(metadata); + GeoKeysWriter geoKeys = null; + if (grid != null && grid.isDefined(GridGeometry.GRID_TO_CRS)) try { + geoKeys = new GeoKeysWriter(store); + geoKeys.write(grid, mf); + } catch (FactoryException | IncommensurableException | RuntimeException e) { + throw new DataStoreReferencingException(e); + } /* * Conversion factor from physical size to pixel size. "Physical size" here should be understood as * paper size, as suggested by the units of measurement which are restricted to inch or centimeters. @@ -311,8 +325,8 @@ final class Writer extends GeoTIFF implements Flushable { writeTag((short) TAG_IMAGE_DESCRIPTION, /* TIFF_ASCII */ mf.title); writeTag((short) TAG_MODEL, /* TIFF_ASCII */ mf.instrument); writeTag((short) TAG_SAMPLES_PER_PIXEL, (short) TIFFTag.TIFF_SHORT, numBands); - writeTag((short) TAG_MIN_SAMPLE_VALUE, (short) TIFFTag.TIFF_SHORT, shortStats[0]); - writeTag((short) TAG_MAX_SAMPLE_VALUE, (short) TIFFTag.TIFF_SHORT, shortStats[1]); + writeTag((short) TAG_MIN_SAMPLE_VALUE, /* TIFF_SHORT */ shortStats[0]); + writeTag((short) TAG_MAX_SAMPLE_VALUE, /* TIFF_SHORT */ shortStats[1]); writeTag((short) TAG_X_RESOLUTION, /* TIFF_RATIONAL */ xres); writeTag((short) TAG_Y_RESOLUTION, /* TIFF_RATIONAL */ yres); writeTag((short) TAG_PLANAR_CONFIGURATION, (short) TIFFTag.TIFF_SHORT, planarConfiguration); @@ -332,6 +346,12 @@ final class Writer extends GeoTIFF implements Flushable { 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]); + if (geoKeys != null) { + writeTag((short) TAG_MODEL_TRANSFORMATION, (short) TIFFTag.TIFF_DOUBLE, geoKeys.modelTransformation()); + writeTag((short) TAG_GEO_KEY_DIRECTORY, /* TIFF_SHORT */ geoKeys.keyDirectory()); + writeTag((short) TAG_GEO_DOUBLE_PARAMS, (short) TIFFTag.TIFF_DOUBLE, geoKeys.doubleParams()); + writeTag((short) TAG_GEO_ASCII_PARAMS, /* TIFF_ASCII */ geoKeys.asciiParams()); + } /* * At this point, all tags have been written. Update the number of tags, * then write all values that couldn't be written directly in the tags. @@ -383,8 +403,8 @@ final class Writer extends GeoTIFF implements Flushable { * @param sampleFormat the sample format. * @return statistics for the tags restricted to integer types. */ - private static int[][] toShorts(final double[][] statistics, final int sampleFormat) { - final int[][] c = new int[statistics.length][]; + private static short[][] toShorts(final double[][] statistics, final int sampleFormat) { + final short[][] c = new short[statistics.length][]; final long min, max; switch (sampleFormat) { case SAMPLE_FORMAT_UNSIGNED_INTEGER: min = 0; max = 0xFFFF; break; @@ -394,9 +414,10 @@ final class Writer extends GeoTIFF implements Flushable { for (int j=0; j < c.length; j++) { final double[] source = statistics[j]; if (source != null) { - final int[] target = new int[source.length]; + final short[] target = new short[source.length]; for (int i=0; i < source.length; i++) { - target[i] = (int) Math.max(min, Math.min(max, Math.round(source[i]))); + target[i] = (short) Math.max(min, Math.min(max, Math.round(source[i]))); + // Unsigned values may look signed after the cast, but this is okay. } c[j] = target; } @@ -504,10 +525,14 @@ final class Writer extends GeoTIFF implements Flushable { if (StandardCharsets.US_ASCII.equals(store.encoding)) { value = CharSequences.toASCII(value).toString(); } - final byte[] c = value.getBytes(store.encoding); - if (c.length != 0) { - count += c.length + 1L; // Count shall include the trailing NUL character. - chars[i] = c; + final byte[] ascii = value.getBytes(store.encoding); + int length = 0; + for (final byte c : ascii) { + if (c != 0) ascii[length++] = c; // Remove any NUL character that may appear in the string. + } + if (length != 0) { + count += length + 1L; // Count shall include the trailing NUL character. + chars[i] = ArraysExt.resize(ascii, length); } } if (count != 0) { diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.java index 6c4e0fb8a7..832cc46ce8 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.java @@ -58,6 +58,26 @@ public class Resources extends IndexedResourceBundle { */ public static final short CanNotComputeGridGeometry_1 = 26; + /** + * Cannot encode {0,choice,0#projection method|1#parameter|2#unit} “{1}” in a GeoTIFF file. + */ + public static final short CanNotEncodeNamedObject_2 = 35; + + /** + * Cannot encode non-geographic base CRS in a GeoTIFF file. + */ + public static final short CanNotEncodeNonGeographicBase = 34; + + /** + * Cannot encode a non-linear model transformation. + */ + public static final short CanNotEncodeNonLinearModel = 36; + + /** + * Cannot encode referencing objects of type ‘{0}’ in a GeoTIFF file. + */ + public static final short CanNotEncodeObjectType_1 = 33; + /** * TIFF file “{0}” has circular references in its chain of images. */ diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.properties b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.properties index 5c320a471e..2d468741eb 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.properties +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.properties @@ -20,6 +20,10 @@ # For resources shared by all modules in the Apache SIS project, see "org.apache.sis.util.resources" package. # CanNotComputeGridGeometry_1 = Cannot compute the grid geometry of \u201c{0}\u201d GeoTIFF file. +CanNotEncodeNonGeographicBase = Cannot encode non-geographic base CRS in a GeoTIFF file. +CanNotEncodeNamedObject_2 = Cannot encode {0,choice,0#projection method|1#parameter|2#unit} \u201c{1}\u201d in a GeoTIFF file. +CanNotEncodeNonLinearModel = Cannot encode a non-linear model transformation. +CanNotEncodeObjectType_1 = Cannot encode referencing objects of type \u2018{0}\u2019 in a GeoTIFF file. CircularImageReference_1 = TIFF file \u201c{0}\u201d has circular references in its chain of images. ConstantValueRequired_3 = Apache SIS implementation requires that all \u201c{0}\u201d elements have the same value, but the element found in \u201c{1}\u201d are {2}. ComputedValueForAttribute_2 = No value specified for the \u201c{0}\u201d TIFF tag. Computed the {1} value from other tags. diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources_fr.properties b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources_fr.properties index 3193a52fd0..54f8030cd0 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources_fr.properties +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources_fr.properties @@ -25,6 +25,10 @@ # U+00A0 NO-BREAK SPACE before : # CanNotComputeGridGeometry_1 = Ne peut pas calculer la g\u00e9om\u00e9trie de la grille du fichier GeoTIFF \u00ab\u202f{0}\u202f\u00bb. +CanNotEncodeNonGeographicBase = Ne peut pas encoder un CRS de base non-g\u00e9ographique dans un fichier GeoTIFF. +CanNotEncodeNamedObject_2 = Ne peut pas encoder {0,choice,0#la m\u00e9thode de projection|1#le param\u00e8tre|2#l\u2019unit\u00e9} \u00ab\u202f{1}\u202f\u00bb dans un fichier GeoTIFF. +CanNotEncodeNonLinearModel = Ne peut pas encoder une transformation de mod\u00e8le non-lin\u00e9aire. +CanNotEncodeObjectType_1 = Ne peut pas encoder des objets g\u00e9od\u00e9tiques de type \u2018{0}\u2019 dans un fichier GeoTIFF. CircularImageReference_1 = Le fichier TIFF \u00ab\u202f{0}\u202f\u00bb a des r\u00e9f\u00e9rences circulaires dans sa cha\u00eene d\u2019images. ConstantValueRequired_3 = L\u2019impl\u00e9mentation de Apache SIS requiert que tous les \u00e9l\u00e9ments de \u00ab\u202f{0}\u202f\u00bb aient la m\u00eame valeur, mais les \u00e9l\u00e9ments trouv\u00e9s dans \u00ab\u202f{1}\u202f\u00bb sont {2}. ComputedValueForAttribute_2 = Aucune valeur n\u2019a \u00e9t\u00e9 sp\u00e9cifi\u00e9e pour le tag TIFF \u00ab\u202f{0}\u202f\u00bb. La valeur {1} a \u00e9t\u00e9 calcul\u00e9e \u00e0 partir des autres tags. diff --git a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoKeysTest.java b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoKeysTest.java index e3611298da..ef149eede8 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoKeysTest.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoKeysTest.java @@ -17,6 +17,7 @@ package org.apache.sis.storage.geotiff; import java.util.Set; +import java.lang.reflect.Field; import org.opengis.metadata.Identifier; import org.opengis.parameter.GeneralParameterDescriptor; import org.opengis.referencing.operation.MathTransformFactory; @@ -126,4 +127,13 @@ public final class GeoKeysTest extends TestCase { throw new AssertionError(e); } } + + /** + * Verifies the value of {@link GeoKeys#NUM_KEYS}. + */ + @Test + public void verifyNumKeys() { + final Field[] fields = GeoKeys.class.getFields(); // Include only public fields. + assertEquals(fields.length, GeoKeys.NUM_KEYS); + } } 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 0f4dcaac3b..3fce1da04a 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 @@ -31,18 +31,27 @@ import java.nio.ByteOrder; import java.awt.image.DataBuffer; import java.awt.image.SampleModel; import javax.imageio.plugins.tiff.TIFFTag; +import static javax.imageio.plugins.tiff.BaselineTIFFTagSet.*; +import org.opengis.referencing.crs.ProjectedCRS; import org.apache.sis.io.stream.ByteArrayChannel; import org.apache.sis.io.stream.ChannelDataOutput; import org.apache.sis.storage.DataStoreException; import org.apache.sis.storage.StorageConnector; +import org.apache.sis.coverage.grid.GridExtent; +import org.apache.sis.coverage.grid.GridGeometry; +import org.apache.sis.coverage.grid.GridOrientation; import org.apache.sis.coverage.grid.j2d.ColorModelFactory; +import org.apache.sis.geometry.Envelope2D; import org.apache.sis.image.DataType; + +// Test dependencies +import org.apache.sis.referencing.operation.HardCodedConversions; +import org.apache.sis.referencing.crs.HardCodedCRS; import org.apache.sis.image.TiledImageMock; import org.apache.sis.test.TestUtilities; import org.apache.sis.test.TestCase; import org.junit.Test; -import static javax.imageio.plugins.tiff.BaselineTIFFTagSet.*; import static org.junit.jupiter.api.Assertions.*; @@ -66,6 +75,11 @@ public final class WriterTest extends TestCase { */ private TiledImageMock image; + /** + * Mapping from pixel coordinates to "real world" coordinates, or {@code null} if none. + */ + private GridGeometry gridGeometry; + /** * The channel where the image is written. * The data can be obtained by a call to {@link ByteArrayChannel#toBuffer()}. @@ -127,7 +141,7 @@ public final class WriterTest extends TestCase { image.validate(); image.initializeAllTiles(); - output = new ByteArrayChannel(new byte[image.getWidth() * image.getHeight() * numBands * type.bytes() + 400], false); + output = new ByteArrayChannel(new byte[image.getWidth() * image.getHeight() * numBands * type.bytes() + 800], false); var d = new ChannelDataOutput("TIFF", output, ByteBuffer.allocate(random.nextInt(128) + 20).order(order)); var c = new StorageConnector(d); c.setOption(GeoTiffOption.OPTION_KEY, options); @@ -135,6 +149,15 @@ public final class WriterTest extends TestCase { data = output.toBuffer().order(order); } + /** + * Creates a grid geometry to associate with the image. This is used for testing GeoTIFF tags. + */ + private void createGridGeometry() { + final ProjectedCRS crs = HardCodedConversions.mercator(HardCodedCRS.WGS84); + final var env = new Envelope2D(crs, -23, -10, TILE_WIDTH * 2, TILE_HEIGHT * 4); + gridGeometry = new GridGeometry(new GridExtent(TILE_WIDTH, TILE_HEIGHT), env, GridOrientation.REFLECTION_Y); + } + /** * Writes a single image and updates the data buffer limit. * After this method call, the {@linkplain #data} position is 0 @@ -147,7 +170,7 @@ public final class WriterTest extends TestCase { private void writeImage() throws IOException, DataStoreException { synchronized (store) { final Writer writer = store.writer(); - writer.append(image, null); + writer.append(image, gridGeometry, null); writer.flush(); } data.clear().limit(Math.toIntExact(output.size())); @@ -224,6 +247,24 @@ public final class WriterTest extends TestCase { store.close(); } + /** + * Tests writing an image with GeoTIFF data. + * + * @throws IOException should never happen since the tests are writing in memory. + * @throws DataStoreException if the image is incompatible with writer capability. + */ + @Test + public void testGeoTIFF() throws IOException, DataStoreException { + initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 1, 1, 1); + createGridGeometry(); + writeImage(); + verifyHeader(false, GeoTIFF.LITTLE_ENDIAN); + verifyImageFileDirectory(Writer.MINIMAL_NUMBER_OF_TAGS + 3, // GeoTIFF adds 3 tags. + PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO, new short[] {Byte.SIZE}); + verifySampleValues(1); + store.close(); + } + /** * Verifies the TIFF header, before the first Image File Directory (IFD). * @@ -297,7 +338,8 @@ public final class WriterTest extends TestCase { long count = isBigTIFF ? data.getLong() : data.getInt(); long value = isBigTIFF ? data.getLong() : data.getInt(); Object expected; // The Number class will define the expected type. - assertTrue(tag > previousTag, "Tags shall be sorted in increasing order."); + assertTrue(Short.toUnsignedInt(tag) > Short.toUnsignedInt(previousTag), + "Tags shall be sorted in increasing order."); expectedTags.remove(Integer.valueOf(tag)); previousTag = tag; switch (tag) { diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java index 36916a9066..b63afc7f6d 100644 --- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java +++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java @@ -2257,9 +2257,9 @@ parse: for (int i = 0; i < length;) { public final void setGridToCRS(final CharSequence value) { final InternationalString i18n = trim(value); if (i18n != null) { - final DefaultGridSpatialRepresentation gridRepresentation = gridRepresentation(); - if (gridRepresentation instanceof DefaultGeorectified) { - ((DefaultGeorectified) gridRepresentation).setTransformationDimensionDescription(i18n); + final DefaultGridSpatialRepresentation r = gridRepresentation(); + if (r instanceof DefaultGeorectified) { + ((DefaultGeorectified) r).setTransformationDimensionDescription(i18n); } } } diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataFetcher.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataFetcher.java index 879b76c1e9..0784362639 100644 --- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataFetcher.java +++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataFetcher.java @@ -20,9 +20,12 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.function.BiPredicate; import java.util.function.Function; +import org.opengis.util.CodeList; import org.opengis.util.InternationalString; +import org.apache.sis.util.collection.CodeListSet; import org.opengis.metadata.Metadata; import org.opengis.metadata.citation.Citation; import org.opengis.metadata.citation.CitationDate; @@ -36,6 +39,10 @@ import org.opengis.metadata.lineage.ProcessStep; import org.opengis.metadata.acquisition.AcquisitionInformation; import org.opengis.metadata.acquisition.Instrument; import org.opengis.metadata.acquisition.Platform; +import org.opengis.metadata.spatial.CellGeometry; +import org.opengis.metadata.spatial.Georectified; +import org.opengis.metadata.spatial.SpatialRepresentation; +import org.opengis.metadata.spatial.GridSpatialRepresentation; /** @@ -106,6 +113,18 @@ public abstract class MetadataFetcher<T> { */ public List<String> procedure; + /** + * General description of the transformation to a georectified grid, or {@code null} if none. + * + * <p>Path: {@code metadata/spatialRepresentationInfo/transformationDimensionDescription}</p> + */ + public List<String> transformationDimension; + + /** + * Whether grid data are representative of pixel areas or points, or {@code null} if none. + */ + public Set<CellGeometry> cellGeometry; + /** * The locale to use for converting international strings to {@link String} objects. * May also be used for date or number formatting. @@ -151,6 +170,7 @@ public abstract class MetadataFetcher<T> { forEach(MetadataFetcher::accept, info.getIdentificationInfo()); forEach(MetadataFetcher::accept, info.getResourceLineages()); forEach(MetadataFetcher::accept, info.getAcquisitionInformation()); + forEach(MetadataFetcher::accept, info.getSpatialRepresentationInfo()); } } @@ -274,6 +294,23 @@ public abstract class MetadataFetcher<T> { return false; } + /** + * Fetches some properties from the given spatial representation object. + * Subclasses can override if they need to fetch more details. + * + * @param info the spatial representation object (not null). + * @return whether to stop iteration after the given object. + */ + protected boolean accept(final SpatialRepresentation info) { + if (info instanceof GridSpatialRepresentation) { + addCode(CellGeometry.class, cellGeometry, ((GridSpatialRepresentation) info).getCellGeometry()); + if (info instanceof Georectified) { + addString(transformationDimension, ((Georectified) info).getTransformationDimensionDescription()); + } + } + return false; + } + /** * Adds all international strings in the given collection. * @@ -327,6 +364,25 @@ public abstract class MetadataFetcher<T> { return target; } + /** + * Adds the given code in the given collection. + * + * @param <E> compile-time value of {@code type} argument. + * @param type type of code list elements. + * @param target collection where to add the code. + * @param value the code to add. + * @return the collection where to code was added. + */ + private static <E extends CodeList<E>> Set<E> addCode(final Class<E> type, Set<E> target, final E value) { + if (value != null) { + if (target == null) { + target = new CodeListSet<>(type); + } + target.add(value); + } + return target; + } + /** * Converts the given date into the object to store. * The {@code <T>} type may be for example {@code <String>} diff --git a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java index c8306d0c0a..2a8f5fe0aa 100644 --- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java +++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java @@ -323,6 +323,11 @@ public class Errors extends IndexedResourceBundle { */ public static final short ForbiddenProperty_1 = 41; + /** + * “{0}” uses two or more different units of measurement. + */ + public static final short HeterogynousUnitsIn_1 = 202; + /** * Identifier “{1}” is not in “{0}” namespace. */ diff --git a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties index 209af64dd2..031bec00d6 100644 --- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties +++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties @@ -77,6 +77,7 @@ FactoryNotFound_1 = No factory of kind \u2018{0}\u2019 found. FileNotFound_1 = File \u201c{0}\u201d has not been found. ForbiddenAttribute_2 = Attribute \u201c{0}\u201d is not allowed for an object of type \u2018{1}\u2019. ForbiddenProperty_1 = Property \u201c{0}\u201d is not allowed. +HeterogynousUnitsIn_1 = \u201c{0}\u201d uses two or more different units of measurement. IdentifierNotInNamespace_2 = Identifier \u201c{1}\u201d is not in \u201c{0}\u201d namespace. IllegalArgumentClass_2 = Argument \u2018{0}\u2019 cannot be an instance of \u2018{1}\u2019. IllegalArgumentClass_3 = Argument \u2018{0}\u2019 cannot be an instance of \u2018{2}\u2019. Expected an instance of \u2018{1}\u2019 or derived type. diff --git a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties index 31480f3820..ff8a5066cb 100644 --- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties +++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties @@ -74,6 +74,7 @@ FactoryNotFound_1 = Aucune fabrique de type \u2018{0}\u2019 n\u2 FileNotFound_1 = Le fichier \u00ab\u202f{0}\u202f\u00bb n\u2019a pas \u00e9t\u00e9 trouv\u00e9. ForbiddenAttribute_2 = L\u2019attribut \u00ab\u202f{0}\u202f\u00bb n\u2019est pas autoris\u00e9 pour un objet de type \u2018{1}\u2019. ForbiddenProperty_1 = La propri\u00e9t\u00e9 \u00ab\u202f{0}\u202f\u00bb n\u2019est pas autoris\u00e9e. +HeterogynousUnitsIn_1 = \u00ab\u202f{0}\u202f\u00bb utilise plusieurs unit\u00e9s de mesures diff\u00e9rentes. IdentifierNotInNamespace_2 = L\u2019identifiant \u201c{1}\u201d n\u2019est pas dans l\u2019espace de noms \u201c{0}\u201d. IllegalArgumentClass_2 = L\u2019argument \u2018{0}\u2019 ne peut pas \u00eatre de type \u2018{1}\u2019. IllegalArgumentClass_3 = L\u2019argument \u2018{0}\u2019 ne peut pas \u00eatre de type \u2018{2}\u2019. Une instance de \u2018{1}\u2019 ou d\u2019un type d\u00e9riv\u00e9 \u00e9tait attendue.