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 c01f85506e80f866b733d1875c1a609c3685f228 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Thu Apr 21 16:48:02 2022 +0200 Add World File write test. --- .../internal/storage/image/WorldFileResource.java | 110 +++++++++++++++++---- .../sis/internal/storage/image/WorldFileStore.java | 20 +++- .../storage/image/WorldFileStoreProvider.java | 3 +- .../internal/storage/image/WritableResource.java | 9 +- .../sis/internal/storage/image/WritableStore.java | 4 +- .../sis/internal/storage/image/package-info.java | 13 +++ .../internal/storage/image/WorldFileStoreTest.java | 94 ++++++++++++++++-- 7 files changed, 212 insertions(+), 41 deletions(-) diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileResource.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileResource.java index c292bc5089..57dee0fc58 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileResource.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileResource.java @@ -19,12 +19,14 @@ package org.apache.sis.internal.storage.image; import java.util.List; import java.util.Optional; import java.io.IOException; +import java.lang.ref.SoftReference; import java.awt.Rectangle; import java.awt.image.RenderedImage; import java.awt.image.BandedSampleModel; import javax.imageio.ImageReader; import javax.imageio.ImageReadParam; import javax.imageio.ImageTypeSpecifier; +import org.opengis.util.LocalName; import org.opengis.util.GenericName; import org.opengis.util.InternationalString; import org.apache.sis.image.ImageProcessor; @@ -73,23 +75,27 @@ class WorldFileResource extends AbstractGridCoverageResource implements StoreRes /** * Index of the image to read or write in the image file. This is usually 0. + * May be decremented when other resources are {@linkplain WritableStore#remove removed}. + * + * @see #getImageIndex() */ - int imageIndex; + private int imageIndex; /** * The identifier as a sequence number in the namespace of the {@link WorldFileStore}. * The first image has the sequence number "1". This is computed when first needed. + * {@link WritableResource} have no identifier because the numbers may change. * * @see #getIdentifier() */ - private GenericName identifier; + private LocalName identifier; /** * The grid geometry of this resource. The grid extent is the image size. * * @see #getGridGeometry() */ - private final GridGeometry gridGeometry; + private GridGeometry gridGeometry; /** * The ranges of sample values, computed when first needed. Shall be an unmodifiable list. @@ -98,6 +104,11 @@ class WorldFileResource extends AbstractGridCoverageResource implements StoreRes */ private List<SampleDimension> sampleDimensions; + /** + * Cached coverage for the full image, or {@code null} if none. + */ + private SoftReference<GridCoverage> fullCoverage; + /** * Creates a new resource. This resource will have its own set of listeners, * but the listeners of the data store that created this resource will be notified as well. @@ -132,16 +143,43 @@ class WorldFileResource extends AbstractGridCoverageResource implements StoreRes throw new DataStoreException(Resources.format(Resources.Keys.ResourceRemoved)); } + /** + * Returns the index of the image to read or write in the image file. This is usually 0. + * Note that contrarily to {@link #getIdentifier()}, this index is not guaranteed to be constant. + */ + final int getImageIndex() { + return imageIndex; + } + + /** + * Decrements the image index. This is needed if images before this image have been removed. + */ + final void decrementImageIndex() throws DataStoreException { + getIdentifier(); // For identifier creation for keeping it constant. + imageIndex--; + } + /** * Returns the resource identifier. The name space is the file name and * the local part of the name is the image index number, starting at 1. + * This identifier should be constant. */ @Override public final Optional<GenericName> getIdentifier() throws DataStoreException { final WorldFileStore store = store(); synchronized (store) { if (identifier == null) { - identifier = Names.createLocalName(store.getDisplayName(), null, String.valueOf(imageIndex + 1)); + // TODO: get `base` from image metadata if available. + final String base = String.valueOf(getImageIndex() + 1); + String id = base; + int n = 0; + while (store.identifiers.putIfAbsent(id, Boolean.TRUE) != null) { + if (--n >= 0) { + throw new ArithmeticException(); // Paranoiac safety for avoiding never-ending loop. + } + id = base + n; + } + identifier = Names.createLocalName(store.getDisplayName(), null, id); } return Optional.of(identifier); } @@ -155,7 +193,9 @@ class WorldFileResource extends AbstractGridCoverageResource implements StoreRes */ @Override public final GridGeometry getGridGeometry() throws DataStoreException { - return gridGeometry; + synchronized (store()) { + return gridGeometry; + } } /** @@ -168,7 +208,7 @@ class WorldFileResource extends AbstractGridCoverageResource implements StoreRes synchronized (store) { if (sampleDimensions == null) try { final ImageReader reader = store.reader(); - final ImageTypeSpecifier type = reader.getRawImageType(imageIndex); + final ImageTypeSpecifier type = reader.getRawImageType(getImageIndex()); final SampleDimension[] bands = new SampleDimension[type.getNumBands()]; final SampleDimension.Builder b = new SampleDimension.Builder(); final short[] names = ImageUtilities.bandNames(type.getColorModel(), type.getSampleModel()); @@ -206,11 +246,17 @@ class WorldFileResource extends AbstractGridCoverageResource implements StoreRes */ @Override public final GridCoverage read(GridGeometry domain, int... range) throws DataStoreException { - RenderedImage image; - List<SampleDimension> bands; + final boolean isFullCoverage = (domain == null && range == null); final WorldFileStore store = store(); try { synchronized (store) { + if (isFullCoverage && fullCoverage != null) { + final GridCoverage coverage = fullCoverage.get(); + if (coverage != null) { + return coverage; + } + fullCoverage = null; + } final ImageReader reader = store.reader(); final ImageReadParam param = reader.getDefaultReadParam(); if (domain == null) { @@ -254,9 +300,9 @@ class WorldFileResource extends AbstractGridCoverageResource implements StoreRes * be the easiest cases. More difficult cases will be handled after the reading. * Those heuristic rules may be changed in any future version. */ - bands = getSampleDimensions(); + List<SampleDimension> bands = getSampleDimensions(); if (range != null) { - final ImageTypeSpecifier type = reader.getRawImageType(imageIndex); + final ImageTypeSpecifier type = reader.getRawImageType(getImageIndex()); final RangeArgument args = RangeArgument.validate(type.getNumBands(), range, listeners); if (args.isIdentity()) { range = null; @@ -270,26 +316,48 @@ class WorldFileResource extends AbstractGridCoverageResource implements StoreRes } } } - image = reader.readAsRenderedImage(imageIndex, param); + RenderedImage image = reader.readAsRenderedImage(getImageIndex(), param); + /* + * If the reader was presumed unable to handle the band subsetting, apply it now. + * It waste some memory because unused bands still in memory. But we do that as a + * workaround for limitations in some `ImageReader` implementations. + */ + if (range != null) { + image = new ImageProcessor().selectBands(image, range); + } + final GridCoverage coverage = new GridCoverage2D(domain, bands, image); + if (isFullCoverage) { + fullCoverage = new SoftReference<>(coverage); + } + return coverage; } } catch (IOException | RuntimeException e) { throw canNotRead(store.getDisplayName(), domain, e); } - /* - * If the reader was presumed unable to handle the band subsetting, apply it now. - * It waste some memory because unused bands still in memory. But we do that as a - * workaround for limitations in some `ImageReader` implementations. - */ - if (range != null) { - image = new ImageProcessor().selectBands(image, range); - } - return new GridCoverage2D(domain, bands, image); + } + + /** + * Sets the grid coverage to the given value. + * This is used during write operations only. + */ + final void setGridCoverage(final GridCoverage coverage) { + sampleDimensions = coverage.getSampleDimensions(); + gridGeometry = coverage.getGridGeometry(); + fullCoverage = new SoftReference<>(coverage); } /** * Notifies this resource that it should not be used anymore. */ final void dispose() { - store = null; + if (identifier != null) { + // For information purpose but not really used. + store.identifiers.put(identifier.toString(), Boolean.FALSE); + } + store = null; + identifier = null; + sampleDimensions = null; + gridGeometry = null; + fullCoverage = null; } } diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStore.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStore.java index 2188e244fe..62052b1731 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStore.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStore.java @@ -18,8 +18,9 @@ package org.apache.sis.internal.storage.image; import java.util.Arrays; import java.util.Collection; -import java.util.LinkedHashMap; import java.util.Map; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.logging.Level; import java.io.IOException; import java.io.EOFException; @@ -201,6 +202,16 @@ class WorldFileStore extends PRJDataStore implements Aggregate { */ private Metadata metadata; + /** + * Identifiers used by a resource. Identifiers must be unique in the data store, + * so after an identifier has been used it can not be reused anymore even if the + * resource having that identifier has been removed. + * Values associated to identifiers tell whether the resource still exist. + * + * @see WorldFileResource#getIdentifier() + */ + final Map<String,Boolean> identifiers; + /** * Creates a new store from the given file, URL or stream. * @@ -214,6 +225,7 @@ class WorldFileStore extends PRJDataStore implements Aggregate { throws DataStoreException, IOException { super(provider, connector); + identifiers = new HashMap<>(); final Object storage = connector.getStorage(); if (storage instanceof ImageReader) { reader = (ImageReader) storage; @@ -689,7 +701,7 @@ loop: for (int convention=0;; convention++) { * @param image the image to add to this list. */ final void added(final WorldFileResource image) { - size = image.imageIndex; + size = image.getImageIndex(); if (size >= images.length) { images = Arrays.copyOf(images, size * 2); } @@ -702,14 +714,14 @@ loop: for (int convention=0;; convention++) { * * @param index index of the image that has been removed. */ - final void removed(int index) { + final void removed(int index) throws DataStoreException { final int last = images.length - 1; System.arraycopy(images, index+1, images, index, last - index); images[last] = null; size--; while (index < last) { final WorldFileResource image = images[index++]; - if (image != null) image.imageIndex--; + if (image != null) image.decrementImageIndex(); } } diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStoreProvider.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStoreProvider.java index 3e06c04f40..6012fd8c67 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStoreProvider.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStoreProvider.java @@ -23,7 +23,6 @@ import java.io.IOException; import javax.imageio.ImageReader; import javax.imageio.ImageWriter; import javax.imageio.spi.ImageReaderSpi; -import org.apache.sis.storage.DataStore; import org.apache.sis.storage.DataStoreException; import org.apache.sis.storage.StorageConnector; import org.apache.sis.internal.storage.Capability; @@ -78,7 +77,7 @@ public final class WorldFileStoreProvider extends PRJDataStore.Provider { * @throws DataStoreException if an error occurred while creating the data store instance. */ @Override - public DataStore open(final StorageConnector connector) throws DataStoreException { + public WorldFileStore open(final StorageConnector connector) throws DataStoreException { final Object storage = connector.getStorage(); boolean isWritable = (storage instanceof ImageWriter); if (!isWritable) { diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableResource.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableResource.java index 5b6405d892..a0c521f6db 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableResource.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableResource.java @@ -60,13 +60,14 @@ final class WritableResource extends WorldFileResource implements WritableGridCo final WritableStore store = (WritableStore) store(); try { synchronized (store) { - if (imageIndex != WorldFileStore.MAIN_IMAGE || (store.isMultiImages() != 0 && !h.replace(null))) { + if (getImageIndex() != WorldFileStore.MAIN_IMAGE || (store.isMultiImages() != 0 && !h.replace(null))) { // TODO: we should use `ImageWriter.replacePixels(…)` methods instead. coverage = h.update(coverage); } - final RenderedImage data = coverage.render(null); // Fail if not two-dimensional. - store.setGridGeometry(imageIndex, coverage.getGridGeometry()); // May use the image reader. - final ImageWriter writer = store.writer(); // Should be after `setGridGeometry(…)`. + final RenderedImage data = coverage.render(null); // Fail if not two-dimensional. + store.setGridGeometry(getImageIndex(), coverage.getGridGeometry()); // May use the image reader. + setGridCoverage(coverage); + final ImageWriter writer = store.writer(); // Should be after `setGridGeometry(…)`. writer.write(data); } } catch (IOException | RuntimeException e) { diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableStore.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableStore.java index c427c2ec93..0e767ffad5 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableStore.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableStore.java @@ -188,7 +188,6 @@ fallback: if (writer == null) { * @see #setGridGeometry(int, GridGeometry) */ final int isMultiImages() throws IOException, DataStoreException { - assert Thread.holdsLock(this); if (numImages < 0) { // This case happens only when we opened an existing file. final Components components = components(true, numImages); @@ -221,6 +220,7 @@ fallback: if (writer == null) { */ @Override String setGridGeometry(final int index, GridGeometry gg) throws IOException, DataStoreException { + assert Thread.holdsLock(this); /* * Make sure that the grid geometry starts at (0,0). * Must be done before to compare with existing grid. @@ -348,7 +348,7 @@ writeCoeffs: for (int i=0;; i++) { if (resource instanceof WritableResource) { final WritableResource image = (WritableResource) resource; if (image.store() == this) try { - final int imageIndex = image.imageIndex; + final int imageIndex = image.getImageIndex(); writer().removeImage(imageIndex); final Components components = components(false, numImages); if (components != null) { diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/package-info.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/package-info.java index 60397f5937..de642ab341 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/package-info.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/package-info.java @@ -23,6 +23,19 @@ * completed with an additional source of information for georeferencing the image. * A commonly-used convention is the <cite>World File</cite> format. * + * <h2>Limitations</h2> + * The World File reader and writer currently have the following limitations. + * Those limitations may be addressed in a future SIS version. + * + * <ul> + * <li>Image metadata are ignored. Some metadata that a future version could use are: + * title, author, description, creation time, <i>etc.</i></li> + * <li>Deferred reading of tiles (using {@code ImageReader.readTileRaster(…)}) + * is not yet implemented.</li> + * <li>Resources have no identifier if the data store is writable, + * because image indices are no longer stable identifiers in such case.</li> + * </ul> + * * @author Martin Desruisseaux (Geomatys) * @version 1.2 * diff --git a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/WorldFileStoreTest.java b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/WorldFileStoreTest.java index 46b90eb276..ec967d2d67 100644 --- a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/WorldFileStoreTest.java +++ b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/WorldFileStoreTest.java @@ -17,12 +17,20 @@ package org.apache.sis.internal.storage.image; import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Files; +import java.nio.file.DirectoryStream; +import java.nio.file.StandardOpenOption; import org.opengis.metadata.Metadata; import org.opengis.metadata.extent.GeographicBoundingBox; import org.opengis.metadata.identification.Identification; +import org.apache.sis.coverage.grid.GridCoverage; import org.apache.sis.storage.DataStoreException; +import org.apache.sis.storage.GridCoverageResource; import org.apache.sis.storage.StorageConnector; import org.apache.sis.storage.ProbeResult; +import org.apache.sis.storage.ResourceAlreadyExistsException; +import org.apache.sis.setup.OptionKey; import org.apache.sis.test.TestCase; import org.junit.Test; @@ -53,21 +61,22 @@ public final strictfp class WorldFileStoreTest extends TestCase { */ @Test public void testProbeContent() throws DataStoreException { - final WorldFileStoreProvider p = new WorldFileStoreProvider(); - final ProbeResult r = p.probeContent(testData()); - assertTrue(r.isSupported()); - assertEquals("image/png", r.getMimeType()); + final WorldFileStoreProvider provider = new WorldFileStoreProvider(); + final ProbeResult result = provider.probeContent(testData()); + assertTrue(result.isSupported()); + assertEquals("image/png", result.getMimeType()); } /** * Tests the metadata of the {@code "gradient.png"} file. * - * @throws DataStoreException if an error occurred while reading the file. - * @throws IOException if an error occurred while creating the image reader instance. + * @throws DataStoreException if an error occurred during Image I/O or data store operations. */ @Test - public void testMetadata() throws DataStoreException, IOException { - try (WorldFileStore store = new WorldFileStore(null, testData(), true)) { + public void testMetadata() throws DataStoreException { + final WorldFileStoreProvider provider = new WorldFileStoreProvider(); + try (WorldFileStore store = provider.open(testData())) { + assertFalse(store instanceof WritableStore); assertEquals("gradient", store.getIdentifier().get().toString()); final Metadata metadata = store.getMetadata(); final Identification id = getSingleton(metadata.getIdentificationInfo()); @@ -86,4 +95,73 @@ public final strictfp class WorldFileStoreTest extends TestCase { assertSame(metadata, store.getMetadata()); } } + + /** + * Tests reading the coverage and writing it in a new file. + * + * @throws DataStoreException if an error occurred during Image I/O or data store operations. + * @throws IOException if an error occurred when creating, reading or deleting temporary files. + */ + @Test + public void testReadWrite() throws DataStoreException, IOException { + final Path directory = Files.createTempDirectory("SIS-"); + try { + final WorldFileStoreProvider provider = new WorldFileStoreProvider(); + try (WorldFileStore source = provider.open(testData())) { + assertFalse(source instanceof WritableStore); + final GridCoverageResource resource = getSingleton(source.components()); + assertEquals("identifier", "1", resource.getIdentifier().get().toString()); + /* + * Above `resource` is the content of "gradient.png" file. + * Write the resource in a new file using a different format. + */ + final Path targetPath = directory.resolve("copy.jpg"); + final StorageConnector connector = new StorageConnector(targetPath); + connector.setOption(OptionKey.OPEN_OPTIONS, new StandardOpenOption[] { + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE + }); + try (WritableStore target = (WritableStore) provider.open(connector)) { + assertEquals(0, target.isMultiImages()); + final WritableResource copy = (WritableResource) target.add(resource); + assertEquals(1, target.isMultiImages()); + assertNotSame(resource, copy); + assertEquals (resource.getGridGeometry(), copy.getGridGeometry()); + assertEquals (resource.getSampleDimensions(), copy.getSampleDimensions()); + /* + * Verify that attempt to write again without `REPLACE` mode fails. + */ + final GridCoverage coverage = resource.read(null, null); + try { + copy.write(coverage); + fail("Should not have replaced existing resource."); + } catch (ResourceAlreadyExistsException e) { + final String message = e.getMessage(); + assertTrue(message, message.contains("1")); // "1" is the image identifier. + } + /* + * Try to write again in `REPLACE` mode. + */ + copy.write(coverage, WritableResource.CommonOption.REPLACE); + assertEquals(1, target.isMultiImages()); + } + /* + * Verify that the 3 files have been written. The JGW file content is verified, + * but the PRJ file content is not fully verified because it may vary. + */ + assertTrue(Files.size(targetPath) > 0); + assertTrue(Files.readAllLines(directory.resolve("copy.prj")) + .stream().anyMatch((line) -> line.contains("WGS 84"))); + assertArrayEquals(new String[] { + "2.8125", "0.0", "0.0", "-2.8125", "-178.59375", "88.59375" + }, Files.readAllLines(directory.resolve("copy.jgw")).toArray()); + } + } finally { + try (DirectoryStream<Path> entries = Files.newDirectoryStream(directory)) { + for (Path entry : entries) { + Files.delete(entry); + } + } + Files.delete(directory); + } + } }