Skip to content
Snippets Groups Projects
CellHandler.java 28.2 KiB
Newer Older
  • Learn to ignore specific revisions
  • import bdv.BigDataViewer;
    import bdv.img.cache.VolatileGlobalCellCache;
    import bdv.img.hdf5.Hdf5ImageLoader;
    import bdv.img.hdf5.Hdf5VolatileShortArrayLoader;
    import bdv.img.remote.AffineTransform3DJsonSerializer;
    import bdv.img.remote.RemoteImageLoader;
    import bdv.img.remote.RemoteImageLoaderMetaData;
    import bdv.spimdata.SequenceDescriptionMinimal;
    import bdv.spimdata.SpimDataMinimal;
    import bdv.spimdata.XmlIoSpimDataMinimal;
    import bdv.util.ThumbnailGenerator;
    
    import com.google.gson.GsonBuilder;
    
    import cz.it4i.qcmp.cache.ICacheFile;
    import cz.it4i.qcmp.cache.QuantizationCacheManager;
    import cz.it4i.qcmp.compression.CompressionOptions;
    import cz.it4i.qcmp.compression.ImageCompressor;
    import cz.it4i.qcmp.data.V3i;
    
    import cz.it4i.qcmp.io.*;
    
    import cz.it4i.qcmp.utilities.Stopwatch;
    
    import mpicbg.spim.data.SpimDataException;
    
    import mpicbg.spim.data.generic.sequence.ImgLoaderHints;
    
    import net.imglib2.cache.CacheLoader;
    import net.imglib2.cache.LoaderCache;
    import net.imglib2.cache.ref.SoftRefLoaderCache;
    
    import net.imglib2.img.array.ArrayImg;
    
    import net.imglib2.img.basictypeaccess.volatiles.array.VolatileShortArray;
    
    import net.imglib2.img.cell.Cell;
    
    import net.imglib2.realtransform.AffineTransform3D;
    
    import net.imglib2.type.numeric.integer.UnsignedShortType;
    
    import org.eclipse.jetty.server.Request;
    import org.eclipse.jetty.server.handler.ContextHandler;
    import org.eclipse.jetty.util.log.Log;
    import org.jdom2.Document;
    import org.jdom2.JDOMException;
    import org.jdom2.input.SAXBuilder;
    import org.jdom2.output.Format;
    import org.jdom2.output.XMLOutputter;
    
    import javax.imageio.ImageIO;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.awt.image.BufferedImage;
    import java.io.*;
    import java.nio.file.Files;
    import java.nio.file.Path;
    import java.nio.file.Paths;
    
    import java.util.ArrayList;
    
    import java.util.Comparator;
    
    import java.util.HashMap;
    
    import java.util.Stack;
    
    import java.util.concurrent.ExecutionException;
    
    import java.util.concurrent.TimeUnit;
    
    import java.util.concurrent.atomic.AtomicInteger;
    
    import java.util.concurrent.atomic.AtomicLong;
    
    public class CellHandler extends ContextHandler {
        private static final org.eclipse.jetty.util.log.Logger LOG = Log.getLogger(CellHandler.class);
    
    
        /**
         * Key for a cell identified by timepoint, setup, level, and index
         * (flattened spatial coordinate).
         */
        public static class Key {
            private final int timepoint;
    
            private final int setup;
    
            private final int level;
    
            private final long index;
    
            private final String[] parts;
    
            /**
             * Create a Key for the specified cell. Note that {@code cellDims} and
             * {@code cellMin} are not used for {@code hashcode()/equals()}.
             *
             * @param timepoint timepoint coordinate of the cell
             * @param setup     setup coordinate of the cell
             * @param level     level coordinate of the cell
             * @param index     index of the cell (flattened spatial coordinate of the
             *                  cell)
             */
            public Key(final int timepoint, final int setup, final int level, final long index, final String[] parts) {
                this.timepoint = timepoint;
                this.setup = setup;
                this.level = level;
                this.index = index;
                this.parts = parts;
    
                int value = Long.hashCode(index);
                value = 31 * value + level;
                value = 31 * value + setup;
                value = 31 * value + timepoint;
                hashcode = value;
            }
    
            @Override
            public boolean equals(final Object other) {
                if (this == other)
                    return true;
                if (!(other instanceof VolatileGlobalCellCache.Key))
                    return false;
                final Key that = (Key) other;
                return (this.index == that.index) && (this.timepoint == that.timepoint) && (this.setup == that.setup) && (this.level == that.level);
            }
    
            final int hashcode;
    
            @Override
            public int hashCode() {
                return hashcode;
            }
        }
    
        private final CacheLoader<Key, Cell<?>> loader;
    
        private final LoaderCache<Key, Cell<?>> cache;
    
    
        /**
         * Full path of the dataset xml file this {@link CellHandler} is serving.
         */
        private final String xmlFilename;
    
        /**
         * Full path of the dataset xml file this {@link CellHandler} is serving,
         * without the ".xml" suffix.
         */
        private final String baseFilename;
    
        private final String dataSetURL;
    
        /**
         * Cached dataset XML to be send to and opened by {@link BigDataViewer}
         * clients.
         */
        private final String datasetXmlString;
    
        /**
         * Cached JSON representation of the {@link RemoteImageLoaderMetaData} to be
         * send to clients.
         */
        private final String metadataJson;
    
        /**
         * Cached dataset.settings XML to be send to clients. May be null if no
         * settings file exists for the dataset.
         */
        private final String settingsXmlString;
    
        /**
         * Full path to thumbnail png.
         */
        private final String thumbnailFilename;
    
    
        /**
         * Compression stuff.
         */
    
        private final BigDataServer.ExtendedCompressionOptions compressionParams;
    
        private boolean compressionSupport = false;
    
        private ArrayList<ICacheFile> cachedCodebooks = null;
        private HashMap<Integer, ImageCompressor> compressors = null;
    
        private ImageCompressor lowestResCompressor = null;
    
        private Stack<MemoryOutputStream> cachedBuffers = null;
        private final int INITIAL_BUFFER_SIZE = 2048;
    
        private final AtomicInteger compressedAccumulation = new AtomicInteger(0);
        private final AtomicInteger uncompressedAccumulation = new AtomicInteger(0);
    
        private final AtomicInteger qcmpCellResponseCount = new AtomicInteger(0);
        private final AtomicLong compressionTimeAccumulation = new AtomicLong(0);
    
        public CellHandler(final String baseUrl,
                           final String xmlFilename,
                           final String datasetName,
                           final String thumbnailsDirectory,
                           final BigDataServer.ExtendedCompressionOptions compressionOps) throws SpimDataException, IOException {
    
            final XmlIoSpimDataMinimal io = new XmlIoSpimDataMinimal();
            final SpimDataMinimal spimData = io.load(xmlFilename);
            final SequenceDescriptionMinimal seq = spimData.getSequenceDescription();
            final Hdf5ImageLoader imgLoader = (Hdf5ImageLoader) seq.getImgLoader();
    
            this.compressionParams = compressionOps;
    
            final Hdf5VolatileShortArrayLoader cacheArrayLoader = imgLoader.getShortArrayLoader();
    
            loader = key -> {
                final int[] cellDims = new int[]{
                        Integer.parseInt(key.parts[5]),
                        Integer.parseInt(key.parts[6]),
                        Integer.parseInt(key.parts[7])};
                final long[] cellMin = new long[]{
                        Long.parseLong(key.parts[8]),
                        Long.parseLong(key.parts[9]),
                        Long.parseLong(key.parts[10])};
                return new Cell<>(cellDims, cellMin, cacheArrayLoader.loadArray(key.timepoint, key.setup, key.level, cellDims, cellMin));
            };
    
            cache = new SoftRefLoaderCache<>();
    
    
            // dataSetURL property is used for providing the XML file by replace
            // SequenceDescription>ImageLoader>baseUrl
            this.xmlFilename = xmlFilename;
            baseFilename = xmlFilename.endsWith(".xml") ? xmlFilename.substring(0, xmlFilename.length() - ".xml".length()) : xmlFilename;
            dataSetURL = baseUrl;
    
            datasetXmlString = buildRemoteDatasetXML(io, spimData, baseUrl);
            metadataJson = buildMetadataJsonString(imgLoader, seq);
            settingsXmlString = buildSettingsXML(baseFilename);
            thumbnailFilename = createThumbnail(spimData, baseFilename, datasetName, thumbnailsDirectory);
    
            final int numberOfMipmapLevels = imgLoader.getSetupImgLoader(0).numMipmapLevels();
    
            initializeCompression(numberOfMipmapLevels, imgLoader);
    
        }
    
        private ImageCompressor getCompressorForMipmapLevel(final int mipmapLevel) {
            assert (compressors != null && !compressors.isEmpty());
            if (compressors.containsKey(mipmapLevel)) {
                return compressors.get(mipmapLevel);
            }
            return lowestResCompressor;
    
        private void initializeCompression(final int numberOfMipmapLevels, final Hdf5ImageLoader hdf5ImageLoader) {
    
            if (compressionParams == null)
                return;
            this.compressionParams.setInputDataInfo(new FileInputData(this.baseFilename));
    
            final QuantizationCacheManager qcm = new QuantizationCacheManager(compressionParams.getCodebookCacheFolder());
    
    
            cachedCodebooks = qcm.loadAvailableCacheFiles(compressionParams);
            if (cachedCodebooks.isEmpty()) {
    
                if (compressionParams.isCodebookTrainingEnabled()) {
    
                    // NOTE(Moravec): Train all possible codebooks from |L| 2 to 256.
                    if (!trainCompressionCodebooks(compressionParams, hdf5ImageLoader)) {
                        LOG.warn("Failed to train compression codebooks.");
                        return;
                    }
    
                    cachedCodebooks = qcm.loadAvailableCacheFiles(compressionParams);
                    if (cachedCodebooks.isEmpty()) {
                        LOG.warn("Failed to train codebooks. Look above for errors.");
                        assert false; // For debug purposes.
                        return;
                    }
                } else {
    
                    LOG.warn("Didn't find any cached codebooks for " + this.baseFilename + " and codebook training is not enabled. " +
                                     "Enable with 'tcb' after xml file path.");
    
            LOG.info(String.format("Found %d codebooks for %s.", cachedCodebooks.size(), this.baseFilename));
    
            final int numberOfCompressors = Math.min((numberOfMipmapLevels - compressionParams.getCompressFromMipmapLevel()),
                                                     cachedCodebooks.size());
    
            cachedCodebooks.sort(Comparator.comparingInt(obj -> obj.getHeader().getBitsPerCodebookIndex()));
            compressors = new HashMap<>(numberOfCompressors);
            for (int compressorIndex = 0; compressorIndex < numberOfCompressors; compressorIndex++) {
                final ICacheFile levelCacheFile = cachedCodebooks.get((cachedCodebooks.size() - 1) - compressorIndex);
                final int bitsPerCodebookIndex = levelCacheFile.getHeader().getBitsPerCodebookIndex();
    
    
                final CompressionOptions compressorOptions = compressionParams.createClone();
                assert (compressorOptions != compressionParams);
                compressorOptions.setBitsPerCodebookIndex(bitsPerCodebookIndex);
    
    
                final ImageCompressor compressor = new ImageCompressor(compressorOptions, levelCacheFile);
    
                final int actualKey = compressorIndex + compressionParams.getCompressFromMipmapLevel();
                compressors.put(actualKey, compressor);
                LOG.info(String.format("  Loaded codebook of size %d for mipmap level %d. '%s'",
                                       levelCacheFile.getHeader().getCodebookSize(),
                                       actualKey,
                                       levelCacheFile.klass()));
                lowestResCompressor = compressor;
    
            }
    
            final int initialCompressionCacheSize = 10;
    
            cachedBuffers = new Stack<>();
            for (int i = 0; i < initialCompressionCacheSize; i++) {
                cachedBuffers.push(new MemoryOutputStream(INITIAL_BUFFER_SIZE));
            }
    
            compressionSupport = true;
        }
    
        public boolean isCompressionSupported() {
            return compressionSupport;
    
        private boolean trainCompressionCodebooks(final BigDataServer.ExtendedCompressionOptions compressionOptions,
                                                  final Hdf5ImageLoader hdf5ImageLoader) {
    
            LOG.info("Training compression codebooks for dataset: " + baseFilename);
    
    
            final ArrayImg<?, ?> arrImg = (ArrayImg<?, ?>) hdf5ImageLoader.getSetupImgLoader(0).getImage(0, 0, ImgLoaderHints.LOAD_COMPLETELY);
    
            assert (arrImg.numDimensions() == 3) : "arrImg.numDimensions() != 3";
    
            assert (compressionOptions.getInputDataInfo().getCacheFileName().equals(baseFilename));
    
    
            final CallbackInputData cid = new CallbackInputData((x, y, z) ->
                                                                {
                                                                    return ((UnsignedShortType) arrImg.getAt(x, y, z)).getInteger();
                                                                },
                                                                compressionOptions.getInputDataInfo().getDimensions(),
                                                                baseFilename);
            cid.setDimension(new V3i((int) arrImg.dimension(0), (int) arrImg.dimension(1), (int) arrImg.dimension(2)));
    
    
            final InputData originalInputData = compressionOptions.getInputDataInfo();
            final boolean originalVerbose = compressionOptions.isVerbose();
            compressionOptions.setVerbose(true);
            compressionOptions.setInputDataInfo(cid);
    
            final ImageCompressor trainingCompressor = new ImageCompressor(compressionOptions);
            final boolean result = trainingCompressor.trainAndSaveAllCodebooks();
    
            compressionOptions.setInputDataInfo(originalInputData);
            compressionOptions.setVerbose(originalVerbose);
    
            LOG.info("Training completed");
    
    
        private synchronized MemoryOutputStream getCachedCompressionBuffer() {
            if (!cachedBuffers.empty()) {
                return cachedBuffers.pop();
            } else {
                return new MemoryOutputStream(INITIAL_BUFFER_SIZE);
            }
        }
    
    
        private synchronized void returnBufferForReuse(final MemoryOutputStream buffer) {
    
            buffer.reset();
            cachedBuffers.push(buffer);
    
        private FlatBufferInputData createInputDataObject(final short[] data, final int[] cellDims) {
            return new FlatBufferInputData(data, new V3i(cellDims[0], cellDims[1], cellDims[2]), InputData.PixelType.Gray16, this.baseFilename);
        }
    
    
        private void respondWithShortArray(final HttpServletResponse response, final short[] data) throws IOException {
    
            final OutputStream responseStream = response.getOutputStream();
    
            final byte[] buf = new byte[2 * data.length];
            for (int i = 0, j = 0; i < data.length; i++) {
                final short s = data[i];
                buf[j++] = (byte) ((s >> 8) & 0xff);
                buf[j++] = (byte) (s & 0xff);
            }
            response.setContentLength(buf.length);
            responseStream.write(buf);
            responseStream.close();
        }
    
    
        private short[] getCachedVolatileCellData(final String[] parts, final int level) {
            final int index = Integer.parseInt(parts[1]);
            final int timepoint = Integer.parseInt(parts[2]);
            final int setup = Integer.parseInt(parts[3]);
    
            final Key key = new Key(timepoint, setup, level, index, parts);
            short[] data;
            try {
                final Cell<?> cell = cache.get(key, loader);
                data = ((VolatileShortArray) cell.getData()).getCurrentStorageArray();
            } catch (final ExecutionException e) {
                data = new short[0];
            }
            return data;
        }
    
    
        public void doHandle(final String target,
                             final Request baseRequest,
                             final HttpServletRequest request,
                             final HttpServletResponse response) throws IOException {
    
            if (target.equals("/settings")) {
                if (settingsXmlString != null)
                    respondWithString(baseRequest, response, "application/xml", settingsXmlString);
                return;
            }
    
            if (target.equals("/png")) {
                provideThumbnail(baseRequest, response);
                return;
            }
    
            final String cellString = request.getParameter("p");
    
            if (cellString == null) {
                respondWithString(baseRequest, response, "application/xml", datasetXmlString);
                return;
            }
    
            final String[] parts = cellString.split("/");
            if (parts[0].equals("cell")) {
    
                final int level = Integer.parseInt(parts[4]);
                final short[] data = getCachedVolatileCellData(parts, level);
    
                compressedAccumulation.addAndGet(data.length * 2);
                uncompressedAccumulation.addAndGet(data.length * 2);
    
                respondWithShortArray(response, data);
    
                response.setContentType("application/octet-stream");
                response.setStatus(HttpServletResponse.SC_OK);
                baseRequest.setHandled(true);
            } else if (parts[0].equals("cell_qcmp")) {
    
                final int mipmapLevel = Integer.parseInt(parts[4]);
    
                final int[] cellDims = new int[]{Integer.parseInt(parts[5]), Integer.parseInt(parts[6]), Integer.parseInt(parts[7])};
    
    Vojtech Moravec's avatar
    Vojtech Moravec committed
    
    
                final short[] data = getCachedVolatileCellData(parts, mipmapLevel);
    
                assert (compressors != null && !compressors.isEmpty());
    
                final FlatBufferInputData inputData = createInputDataObject(data, cellDims);
    
                final MemoryOutputStream cellCompressionStream = getCachedCompressionBuffer();
    
                final Stopwatch compressionStopwatch = Stopwatch.startNew();
    
    
                final int compressedContentLength = getCompressorForMipmapLevel(mipmapLevel).streamCompressChunk(cellCompressionStream,
                                                                                                                 inputData);
    
                compressionStopwatch.stop();
                compressionTimeAccumulation.addAndGet(compressionStopwatch.getElapsedInUnit(TimeUnit.NANOSECONDS));
                qcmpCellResponseCount.incrementAndGet();
    
                response.setContentLength(compressedContentLength);
    
                try (final OutputStream responseStream = response.getOutputStream()) {
    
                    responseStream.write(cellCompressionStream.getBuffer(), 0, cellCompressionStream.getCurrentBufferLength());
    
                assert (cellCompressionStream.getCurrentBufferLength() == compressedContentLength) :
                        "compressor.streamCompressChunk() is not equal to cachedCompressionStream.getCurrentBufferLength()";
    
                if (cellCompressionStream.getCurrentBufferLength() != compressedContentLength) {
                    System.err.printf("stream size\t%d\nreported size\t%d\n\n",
                                      cellCompressionStream.getCurrentBufferLength(),
                                      compressedContentLength);
                }
    
    
    
                response.setContentType("application/octet-stream");
                response.setStatus(HttpServletResponse.SC_OK);
                baseRequest.setHandled(true);
    
                returnBufferForReuse(cellCompressionStream);
    
                final long currentlySent = compressedAccumulation.addAndGet(compressedContentLength);
                final long uncompressedWouldSent = uncompressedAccumulation.addAndGet(data.length * 2);
    
    Vojtech Moravec's avatar
    Vojtech Moravec committed
                if (compressionParams.isVerbose()) {
    
                    LOG.info(String.format("Sending %dB instead of %dB. Currently sent %dB instead of %dB.",
    
    Vojtech Moravec's avatar
    Vojtech Moravec committed
                                           compressedContentLength,
                                           (data.length * 2),
                                           currentlySent,
    
                                           uncompressedWouldSent));
    
            } else if (parts[0].equals("init")) {
                respondWithString(baseRequest, response, "application/json", metadataJson);
            } else if (parts[0].equals("init_qcmp")) {
    
                respondWithCompressionInfo(baseRequest, response);
    
            } else if (parts[0].equals("qcmp_summary")) {
                respondWithCompressionSummary(baseRequest, response);
    
        private void respondWithCompressionSummary(final Request baseRequest, final HttpServletResponse response) throws IOException {
    
            final long currentlySent = compressedAccumulation.get();
            final long uncompressedWouldSent = uncompressedAccumulation.get();
    
            final int qcmpRequestCount = qcmpCellResponseCount.get();
            final long accumulatedNs = compressionTimeAccumulation.get();
    
            final long totalRequestTimeMs = TimeUnit.MILLISECONDS.convert(accumulatedNs, TimeUnit.NANOSECONDS);
            final long averageTimePerRequestMs = (long) ((double) totalRequestTimeMs / (double) qcmpRequestCount);
    
    
            final double sentKB = ((double) currentlySent / 1000.0);
            final double sentMB = ((double) currentlySent / 1000.0) / 1000.0;
            final double wouldSentKB = ((double) uncompressedWouldSent / 1000.0);
            final double wouldSentMB = ((double) uncompressedWouldSent / 1000.0) / 1000.0;
            final double percentage = (double) currentlySent / (double) uncompressedWouldSent;
    
    
            final String msg = String.format("Currently sent %d B (%.1f KB, %.1f MB) instead of %d B (%.1f KB, %.1f MB).\nPercentage: %.3f\n" +
                                                     "Total compression time: %d ms, Average time per request: %d ms",
                                             currentlySent, sentKB, sentMB, uncompressedWouldSent, wouldSentKB, wouldSentMB, percentage,
                                             totalRequestTimeMs, averageTimePerRequestMs);
    
    
            respondWithString(baseRequest, response, "text/plain",
    
                              HttpServletResponse.SC_OK);
    
        }
    
    
        private void provideThumbnail(final Request baseRequest, final HttpServletResponse response) throws IOException {
            final Path path = Paths.get(thumbnailFilename);
            if (Files.exists(path)) {
                final byte[] imageData = Files.readAllBytes(path);
                if (imageData != null) {
                    response.setContentType("image/png");
                    response.setContentLength(imageData.length);
                    response.setStatus(HttpServletResponse.SC_OK);
                    baseRequest.setHandled(true);
    
                    final OutputStream os = response.getOutputStream();
                    os.write(imageData);
                    os.close();
                }
            }
        }
    
    
        private void respondWithCompressionInfo(final Request baseRequest, final HttpServletResponse response) throws IOException {
            if (cachedCodebooks == null || cachedCodebooks.isEmpty()) {
                LOG.info("QCMP initialization request was refused, QCMP compression is not enabled.");
                respondWithString(baseRequest, response,
                                  "text/plain", "QCMP Compression wasn't enabled on BigDataViewer server.",
                                  HttpServletResponse.SC_BAD_REQUEST);
                return;
    
    
            try (final DataOutputStream dos = new DataOutputStream(response.getOutputStream())) {
    
                dos.writeByte(compressionParams.getCompressFromMipmapLevel());
    
                dos.writeByte(cachedCodebooks.size());
                for (final ICacheFile cacheFile : cachedCodebooks) {
                    cacheFile.writeToStream(dos);
                }
    
                LOG.info(String.format("init_qcmp responding with %d bytes", dos.size()));
    
            } // Stream gets closed here.
    
            response.setContentType("application/octet-stream");
            response.setStatus(HttpServletResponse.SC_OK);
            baseRequest.setHandled(true);
    
        }
    
        public String getXmlFile() {
            return xmlFilename;
        }
    
        public String getDataSetURL() {
            return dataSetURL;
        }
    
        public String getThumbnailUrl() {
            return dataSetURL + "png";
        }
    
        public String getDescription() {
            throw new UnsupportedOperationException();
        }
    
        /**
         * Create a JSON representation of the {@link RemoteImageLoaderMetaData}
         * (image sizes and resolutions) provided by the given
         * {@link Hdf5ImageLoader}.
         */
        private static String buildMetadataJsonString(final Hdf5ImageLoader imgLoader, final SequenceDescriptionMinimal seq) {
            final RemoteImageLoaderMetaData metadata = new RemoteImageLoaderMetaData(imgLoader, seq);
            final GsonBuilder gsonBuilder = new GsonBuilder();
            gsonBuilder.registerTypeAdapter(AffineTransform3D.class, new AffineTransform3DJsonSerializer());
            gsonBuilder.enableComplexMapKeySerialization();
            return gsonBuilder.create().toJson(metadata);
        }
    
        /**
         * Create a modified dataset XML by replacing the ImageLoader with an
         * {@link RemoteImageLoader} pointing to the data we are serving.
         */
    
        private static String buildRemoteDatasetXML(final XmlIoSpimDataMinimal io,
                                                    final SpimDataMinimal spimData,
                                                    final String baseUrl) throws IOException, SpimDataException {
    
            final SpimDataMinimal s = new SpimDataMinimal(spimData, new RemoteImageLoader(baseUrl, false));
            final Document doc = new Document(io.toXml(s, s.getBasePath()));
            final XMLOutputter xout = new XMLOutputter(Format.getPrettyFormat());
            final StringWriter sw = new StringWriter();
            xout.output(doc, sw);
            return sw.toString();
        }
    
        /**
         * Read {@code baseFilename.settings.xml} into a string if it exists.
         *
         * @return contents of {@code baseFilename.settings.xml} or {@code null} if
         * that file couldn't be read.
         */
        private static String buildSettingsXML(final String baseFilename) {
            final String settings = baseFilename + ".settings.xml";
            if (new File(settings).exists()) {
                try {
                    final SAXBuilder sax = new SAXBuilder();
                    final Document doc = sax.build(settings);
                    final XMLOutputter xout = new XMLOutputter(Format.getPrettyFormat());
                    final StringWriter sw = new StringWriter();
                    xout.output(doc, sw);
                    return sw.toString();
    
                } catch (final JDOMException | IOException e) {
    
                    LOG.warn("Could not read settings file \"" + settings + "\"");
                    LOG.warn(e.getMessage());
                }
            }
            return null;
        }
    
        /**
         * Create PNG thumbnail file named "{@code <baseFilename>.png}".
         */
    
        private static String createThumbnail(final SpimDataMinimal spimData,
                                              final String baseFilename,
                                              final String datasetName,
                                              final String thumbnailsDirectory) {
    
            final String thumbnailFileName = thumbnailsDirectory + "/" + datasetName + ".png";
            final File thumbnailFile = new File(thumbnailFileName);
            if (!thumbnailFile.isFile()) // do not recreate thumbnail if it already exists
            {
    
                final BufferedImage bi = ThumbnailGenerator.makeThumbnail(spimData,
                                                                          baseFilename,
                                                                          Constants.THUMBNAIL_WIDTH,
                                                                          Constants.THUMBNAIL_HEIGHT);
    
                try {
                    ImageIO.write(bi, "png", thumbnailFile);
                } catch (final IOException e) {
                    LOG.warn("Could not create thumbnail png for dataset \"" + baseFilename + "\"");
                    LOG.warn(e.getMessage());
                }
            }
            return thumbnailFileName;
        }
    
        /**
         * Handle request by sending a UTF-8 string.
         */
    
        private static void respondWithString(final Request baseRequest,
                                              final HttpServletResponse response,
                                              final String contentType,
                                              final String string) throws IOException {
            respondWithString(baseRequest, response, contentType, string, HttpServletResponse.SC_OK);
        }
    
        /**
         * Handle request by sending a UTF-8 string.
         */
        private static void respondWithString(final Request baseRequest,
                                              final HttpServletResponse response,
                                              final String contentType,
                                              final String string,
                                              final int httpStatus) throws IOException {
    
            response.setContentType(contentType);
            response.setCharacterEncoding("UTF-8");
    
            response.setStatus(httpStatus);
    
            baseRequest.setHandled(true);
    
            final PrintWriter ow = response.getWriter();
            ow.write(string);
            ow.close();
        }