/**
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @author Arne Kepp, The Open Planning Project, Copyright 2008
*/
package org.geowebcache.layer;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.awt.image.renderable.ParameterBlock;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Vector;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageInputStream;
import javax.imageio.stream.ImageOutputStream;
import javax.imageio.stream.MemoryCacheImageOutputStream;
import javax.media.jai.ImageLayout;
import javax.media.jai.JAI;
import javax.media.jai.PlanarImage;
import javax.media.jai.RenderedOp;
import javax.media.jai.operator.CropDescriptor;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.geowebcache.GeoWebCacheException;
import org.geowebcache.grid.BoundingBox;
import org.geowebcache.grid.GridSubset;
import org.geowebcache.grid.SRS;
import org.geowebcache.io.ByteArrayResource;
import org.geowebcache.io.Resource;
import org.geowebcache.mime.FormatModifier;
import org.geowebcache.mime.ImageMime;
import org.geowebcache.mime.MimeType;
import org.springframework.util.Assert;
import it.geosolutions.jaiext.BufferedImageAdapter;
public class MetaTile implements TileResponseReceiver {
private static Log log = LogFactory.getLog(MetaTile.class);
protected static final RenderingHints NO_CACHE = new RenderingHints(JAI.KEY_TILE_CACHE, null);
private static final boolean NATIVE_JAI_AVAILABLE;
static {
// we directly access the Mlib Image class, if in the classpath it will tell us if
// the native extensions are available, if not, an Error will be thrown
boolean nativeJAIAvailable;
try {
Class<?> image = Class.forName("com.sun.medialib.mlib.Image");
nativeJAIAvailable = (Boolean) image.getMethod("isAvailable").invoke(null);
} catch (Throwable e) {
nativeJAIAvailable = false;
}
NATIVE_JAI_AVAILABLE = nativeJAIAvailable;
if (!NATIVE_JAI_AVAILABLE) {
log.warn("********* Native JAI is not installed, meta tile cropping may be slow ********");
}
}
// buffer for storing the metatile, if it is an image
protected RenderedImage metaTileImage = null;
protected int[] gutter = new int[4]; // L,B,R,T in pixels
protected final Rectangle[] tiles;
// minx,miny,maxx,maxy,zoomlevel
protected long[] metaGridCov = null;
// the grid positions of the individual tiles
protected long[][] tilesGridPositions = null;
// X metatiling factor, after adjusting to bounds
protected int metaX;
// Y metatiling factor, after adjusting to bounds
protected int metaY;
protected GridSubset gridSubset;
protected long status = -1;
protected boolean error = false;
protected String errorMessage;
protected long expiresHeader = -1;
protected MimeType responseFormat;
protected FormatModifier formatModifier;
private int gutterConfig;
private BoundingBox metaBbox;
private int metaTileWidth;
private int metaTileHeight;
private List<RenderedImage> disposableImages;
/**
* The the request format is the format used for the request to the backend.
*
* The response format is what the tiles are actually saved as. The primary example is to use
* image/png or image/tiff for backend requests, and then save the resulting tiles to JPEG to
* avoid loss of quality.
*
* @param srs
* @param responseFormat
* @param requestFormat
* @param tileGridPosition
* @param metaX
* @param metaY
* @param gutter2
*/
public MetaTile(GridSubset gridSubset, MimeType responseFormat, FormatModifier formatModifier,
long[] tileGridPosition, int metaX, int metaY, Integer gutter) {
this.gridSubset = gridSubset;
this.responseFormat = responseFormat;
this.formatModifier = formatModifier;
this.metaX = metaX;
this.metaY = metaY;
this.gutterConfig = responseFormat.isVector() || gutter == null ? 0 : gutter.intValue();
metaGridCov = calculateMetaTileGridBounds(
gridSubset.getCoverage((int) tileGridPosition[2]), tileGridPosition);
tilesGridPositions = calculateTilesGridPositions();
calculateEdgeGutter();
int tileHeight = gridSubset.getTileHeight();
int tileWidth = gridSubset.getTileWidth();
this.tiles = createTiles(tileHeight, tileWidth);
}
/***
* Calculates final meta tile width, height and bounding box
* <p>
* Adding a gutter should be really easy, just add to all sides, right ?
*
* But GeoServer / GeoTools, and possibly other WMS servers, can get mad if we exceed 180,90 (or
* the equivalent for other projections), so we'lll treat those with special care.
* </p>
*
* @param strBuilder
* @param metaTileGridBounds
*/
protected void calculateEdgeGutter() {
Arrays.fill(this.gutter, 0);
long[] layerCov = gridSubset.getCoverage((int) this.metaGridCov[4]);
this.metaBbox = gridSubset.boundsFromRectangle(metaGridCov);
this.metaTileWidth = metaX * gridSubset.getTileWidth();
this.metaTileHeight = metaY * gridSubset.getTileHeight();
double widthRelDelta = ((1.0 * metaTileWidth + gutterConfig) / metaTileWidth) - 1.0;
double heightRelDelta = ((1.0 * metaTileHeight + gutterConfig) / metaTileHeight) - 1.0;
double coordWidth = metaBbox.getWidth();
double coordHeight = metaBbox.getHeight();
double coordWidthDelta = coordWidth * widthRelDelta;
double coordHeightDelta = coordHeight * heightRelDelta;
if (layerCov[0] < metaGridCov[0]) {
metaTileWidth += gutterConfig;
gutter[0] = gutterConfig;
metaBbox.setMinX(metaBbox.getMinX() - coordWidthDelta);
}
if (layerCov[1] < metaGridCov[1]) {
metaTileHeight += gutterConfig;
gutter[1] = gutterConfig;
metaBbox.setMinY(metaBbox.getMinY() - coordHeightDelta);
}
if (layerCov[2] > metaGridCov[2]) {
metaTileWidth += gutterConfig;
gutter[2] = gutterConfig;
metaBbox.setMaxX(metaBbox.getMaxX() + coordWidthDelta);
}
if (layerCov[3] > metaGridCov[3]) {
metaTileHeight += gutterConfig;
gutter[3] = gutterConfig;
metaBbox.setMaxY(metaBbox.getMaxY() + coordHeightDelta);
}
}
public BoundingBox getMetaTileBounds() {
return metaBbox;
}
public int getMetaTileWidth() {
return metaTileWidth;
}
public int getMetaTileHeight() {
return metaTileHeight;
}
public int getStatus() {
return (int) status;
}
public void setStatus(int status) {
this.status = (long) status;
}
public boolean getError() {
return this.error;
}
public void setError() {
this.error = true;
}
public String getErrorMessage() {
return this.errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public long getExpiresHeader() {
return this.expiresHeader;
}
public void setExpiresHeader(long seconds) {
this.expiresHeader = seconds;
}
public void setImageBytes(Resource buffer) throws GeoWebCacheException {
Assert.notNull(buffer, "WMSMetaTile.setImageBytes() received null");
Assert.isTrue(buffer.getSize() > 0, "WMSMetaTile.setImageBytes() received empty contents");
try {
ImageInputStream imgStream;
imgStream = new ResourceImageInputStream(((ByteArrayResource) buffer).getInputStream());
RenderedImage metaTiledImage = ImageIO.read(imgStream);// read closes the stream for us
setImage(metaTiledImage);
} catch (IOException ioe) {
throw new GeoWebCacheException("WMSMetaTile.setImageBytes() "
+ "failed on ImageIO.read(byte[" + buffer.getSize() + "])", ioe);
}
if (metaTileImage == null) {
throw new GeoWebCacheException(
"ImageIO.read(InputStream) returned null. Unable to read image.");
}
}
public void setImage(RenderedImage metaTiledImage) {
this.metaTileImage = metaTiledImage;
}
/**
* Cuts the metaTile into the specified number of tiles, the actual number of tiles is
* determined by metaX and metaY, not the width and height provided here.
*
* @param tileWidth
* width of each tile
* @param tileHeight
* height of each tile
* @return
*/
private Rectangle[] createTiles(int tileHeight, int tileWidth) {
int tileCount = metaX * metaY;
Rectangle[] tiles = new Rectangle[tileCount];
for (int y = 0; y < metaY; y++) {
for (int x = 0; x < metaX; x++) {
int i = x * tileWidth + gutter[0];
int j = (metaY - 1 - y) * tileHeight + gutter[3];
// tiles[y * metaX + x] = createTile(i, j, tileWidth, tileHeight, useJAI);
tiles[y * metaX + x] = new Rectangle(i, j, tileWidth, tileHeight);
}
}
return tiles;
}
/**
* Extracts a single tile from the metatile.
*
* @param minX
* left pixel index to crop the meta tile at
* @param minY
* top pixel index to crop the meta tile at
* @param tileWidth
* width of the tile
* @param tileHeight
* height of the tile
* @return a rendered image of the specified meta tile region
*/
public RenderedImage createTile(final int minX, final int minY, final int tileWidth,
final int tileHeight) {
// optimize if we get a bufferedimage
if(metaTileImage instanceof BufferedImage){
BufferedImage subimage = ((BufferedImage) metaTileImage).getSubimage(minX, minY, tileWidth, tileHeight);
return new BufferedImageAdapter(subimage);
}
// do a crop, and then turn it into a buffered image so that we can release
// the image chain
PlanarImage cropped = CropDescriptor.create(metaTileImage, Float.valueOf(minX),
Float.valueOf(minY), Float.valueOf(tileWidth), Float.valueOf(tileHeight), NO_CACHE);
if (nativeAccelAvailable()) {
log.trace("created cropped tile");
return cropped;
}
log.trace("native accel not available, returning buffered image");
BufferedImage tile = cropped.getAsBufferedImage();
disposePlanarImageChain(cropped, new HashSet<PlanarImage>());
return tile;
}
protected boolean nativeAccelAvailable() {
return NATIVE_JAI_AVAILABLE;
}
/**
* Outputs one tile from the internal array of tiles to a provided stream
*
* @param tileIdx
* the index of the tile relative to the internal array
* @param format
* the Java name for the format
* @param resource
* the outputstream
* @return true if no error was encountered
* @throws IOException
*/
public boolean writeTileToStream(final int tileIdx, Resource target) throws IOException {
if (tiles == null) {
return false;
}
if (log.isDebugEnabled()) {
log.debug("Thread: " + Thread.currentThread().getName() + " writing: " + tileIdx);
}
Rectangle tileRegion = tiles[tileIdx];
RenderedImage tile = createTile(tileRegion.x, tileRegion.y, tileRegion.width,
tileRegion.height);
disposeLater(tile);
// TODO should we recycle the writers ?
// GR: it'd be only a 2% perf gain according to profile
ImageWriter writer = ((ImageMime) responseFormat).getImageWriter(tile);
ImageWriteParam param = writer.getDefaultWriteParam();
tile = preprocessForWriter(tile, writer);
if (this.formatModifier != null) {
param = formatModifier.adjustImageWriteParam(param);
}
OutputStream outputStream = target.getOutputStream();
ImageOutputStream imgOut = new MemoryCacheImageOutputStream(outputStream);
writer.setOutput(imgOut);
IIOImage image = new IIOImage(tile, null, null);
try {
writer.write(null, image, param);
} finally {
imgOut.close();
writer.dispose();
}
return true;
}
private RenderedImage preprocessForWriter(RenderedImage ri, ImageWriter writer) {
if(ri.getColorModel().hasAlpha() && ri.getSampleModel().getNumBands() == 4 && isJpegWriter(writer)) {
final int[] bands = new int[3];
for (int i = 0; i < bands.length; i++) {
bands[i] = i;
}
// ParameterBlock creation
ParameterBlock pb = new ParameterBlock();
pb.setSource(ri, 0);
pb.set(bands, 0);
final RenderingHints hints = new RenderingHints(JAI.KEY_IMAGE_LAYOUT, new ImageLayout(ri));
ri = JAI.create("BandSelect", pb, hints);
}
return ri;
}
private boolean isJpegWriter(ImageWriter writer) {
for (String format : writer.getOriginatingProvider().getFormatNames()) {
if(format.equalsIgnoreCase("jpeg")) {
return true;
}
}
return false;
}
protected void disposeLater(RenderedImage tile) {
if (disposableImages == null) {
disposableImages = new ArrayList<RenderedImage>(tiles.length);
}
disposableImages.add(tile);
}
public String debugString() {
return " metaX: " + metaX + " metaY: " + metaY + " metaGridCov: "
+ Arrays.toString(metaGridCov);
}
/**
* Figures out the bounds of the metatile, in terms of the gridposition of all contained tiles.
* To get the BBOX you need to add one tilewidth to the top and right.
*
* It also updates metaX and metaY to the actual metatiling factors
*
* @param gridBounds
* @param tileGridPosition
* @return
*/
private long[] calculateMetaTileGridBounds(long[] coverage, long[] tileIdx) {
long[] metaGridCov = new long[5];
metaGridCov[0] = tileIdx[0] - (tileIdx[0] % metaX);
metaGridCov[1] = tileIdx[1] - (tileIdx[1] % metaY);
metaGridCov[2] = Math.min(metaGridCov[0] + metaX - 1, coverage[2]);
metaGridCov[3] = Math.min(metaGridCov[1] + metaY - 1, coverage[3]);
metaGridCov[4] = tileIdx[2];
// Save the actual metatiling factor, important at the boundaries
metaX = (int) (metaGridCov[2] - metaGridCov[0] + 1);
metaY = (int) (metaGridCov[3] - metaGridCov[1] + 1);
return metaGridCov;
}
/**
* Creates an array with all the grid positions, used for cache keys
*/
private long[][] calculateTilesGridPositions() {
if (metaX < 0 || metaY < 0) {
return null;
}
long[][] tilesGridPos = new long[metaX * metaY][3];
for (int y = 0; y < metaY; y++) {
for (int x = 0; x < metaX; x++) {
int tile = y * metaX + x;
tilesGridPos[tile][0] = metaGridCov[0] + x;
tilesGridPos[tile][1] = metaGridCov[1] + y;
tilesGridPos[tile][2] = metaGridCov[4];
}
}
return tilesGridPos;
}
/**
* The bottom left grid position and zoomlevel for this metatile, used for locking.
*
* @return
*/
public long[] getMetaGridPos() {
long[] gridPos = { metaGridCov[0], metaGridCov[1], metaGridCov[4] };
return gridPos;
}
/**
* The bounds for the metatile
*
* @return
*/
public long[] getMetaTileGridBounds() {
return metaGridCov;
}
public long[][] getTilesGridPositions() {
return tilesGridPositions;
}
public SRS getSRS() {
return this.gridSubset.getSRS();
}
public MimeType getResponseFormat() {
return this.responseFormat;
}
public MimeType getRequestFormat() {
if (formatModifier == null) {
return this.responseFormat;
} else {
return this.formatModifier.getRequestFormat();
}
}
/**
* Should be called as soon as the meta tile is no longer needed in order to dispose any held
* resource
*/
public void dispose() {
if (metaTileImage == null) {
return;
}
RenderedImage image = metaTileImage;
metaTileImage = null;
if (log.isTraceEnabled()) {
log.trace("disposing metatile " + image);
}
if (image instanceof BufferedImage) {
((BufferedImage) image).flush();
} else if (image instanceof PlanarImage) {
disposePlanarImageChain((PlanarImage) image, new HashSet<PlanarImage>());
}
if (disposableImages != null) {
for (RenderedImage tile : disposableImages) {
if (log.isTraceEnabled()) {
log.trace("disposing tile " + tile);
}
if (tile instanceof BufferedImage) {
((BufferedImage) tile).flush();
} else if (tile instanceof PlanarImage) {
disposePlanarImageChain((PlanarImage) tile, new HashSet<PlanarImage>());
}
}
}
disposableImages = null;
}
@SuppressWarnings("rawtypes")
protected static void disposePlanarImageChain(PlanarImage pi, HashSet<PlanarImage> visited) {
Vector sinks = pi.getSinks();
// check all the sinks (the image might be in the middle of a chain)
if (sinks != null) {
for (Object sink : sinks) {
if (sink instanceof PlanarImage && !visited.contains(sink)) {
disposePlanarImageChain((PlanarImage) sink, visited);
} else if (sink instanceof BufferedImage) {
((BufferedImage) sink).flush();
}
}
}
// dispose the image itself
pi.dispose();
visited.add(pi);
// check the image sources
Vector sources = pi.getSources();
if (sources != null) {
for (Object child : sources) {
if (child instanceof PlanarImage && !visited.contains(child)) {
disposePlanarImageChain((PlanarImage) child, visited);
} else if (child instanceof BufferedImage) {
((BufferedImage) child).flush();
}
}
}
// ImageRead might also hold onto a image input stream that we have to close
if (pi instanceof RenderedOp) {
RenderedOp op = (RenderedOp) pi;
for (Object param : op.getParameterBlock().getParameters()) {
if (param instanceof ImageInputStream) {
ImageInputStream iis = (ImageInputStream) param;
try {
iis.close();
} catch (IOException e) {
// fine, we tried
}
}
}
}
}
}