/**
* 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, OpenGeo, Copyright 2009
*/
package org.geowebcache.service.wms;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import javax.media.jai.PlanarImage;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.geotools.resources.image.ImageUtilities;
import org.geowebcache.GeoWebCacheException;
import org.geowebcache.conveyor.Conveyor.CacheResult;
import org.geowebcache.conveyor.ConveyorTile;
import org.geowebcache.filter.request.RequestFilterException;
import org.geowebcache.grid.BoundingBox;
import org.geowebcache.grid.GridSubset;
import org.geowebcache.grid.OutsideCoverageException;
import org.geowebcache.grid.SRS;
import org.geowebcache.io.ImageDecoderContainer;
import org.geowebcache.io.ImageEncoderContainer;
import org.geowebcache.io.Resource;
import org.geowebcache.layer.TileLayer;
import org.geowebcache.layer.TileLayerDispatcher;
import org.geowebcache.layer.wms.WMSLayer;
import org.geowebcache.mime.ImageMime;
import org.geowebcache.mime.MimeType;
import org.geowebcache.stats.RuntimeStats;
import org.geowebcache.storage.StorageBroker;
import org.geowebcache.util.AccountingOutputStream;
import org.geowebcache.util.ServletUtils;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
/*
* It will work as follows
* 2) Based on the dimensions and bounding box of the request, GWC will determine the smallest available resolution that equals or exceeds the requested resolution.
* 3) GWC will create a new in-memory raster, based on the best resolution and requested bounding box, and write the appropriate PNG tiles to it. Missing tiles will be requested from WMS.
* 4) GWC will scale the raster down to the requested dimensions.
* 5) GWC will then compress the raster to the desired output format and return the image. The image is not cached.
*/
public class WMSTileFuser{
private static Log log = LogFactory.getLog(WMSTileFuser.class);
private ApplicationContext applicationContext;
final StorageBroker sb;
final GridSubset gridSubset;
final TileLayer layer;
final ImageMime outputFormat;
ImageMime srcFormat;
int reqHeight;
int reqWidth;
// The desired extent of the request
final BoundingBox reqBounds;
// Boolean reqTransparent;
// String reqBgColor;
// For adjustment of final raster
double xResolution;
double yResolution;
// The source resolution
/* Follows GIS rather than Graphics conventions and so is expressed as physical size of pixel
* rather than density.*/
double srcResolution;
int srcIdx;
// Area of tiles being used in tile coordinates
long[] srcRectangle;
// The spatial extent of the tiles used to fulfil the request
BoundingBox srcBounds;
//
BoundingBox canvasBounds;
/**Canvas dimensions*/
int[] canvasSize = new int[2];
static class SpatialOffsets {
double top;
double bottom;
double left;
double right;
};
static class PixelOffsets {
int top;
int bottom;
int left;
int right;
};
/** These are values before scaling */
PixelOffsets canvOfs = new PixelOffsets();
SpatialOffsets boundOfs = new SpatialOffsets();
/** Mosaic image*/
BufferedImage canvas;
/** Graphics object used for drawing the tiles into a mosaic*/
Graphics2D gfx;
/**Layer parameters*/
private Map<String, String> fullParameters;
/** Map of all the possible decoders to use*/
private ImageDecoderContainer decoderMap;
/** Map of all the possible encoders to use*/
private ImageEncoderContainer encoderMap;
/** Hints used for writing the BufferedImage on the canvas*/
private RenderingHints hints;
/**
*Enum storing the Hints associated to one of the 3 configurations(SPEED, QUALITY, DEFAULT)
*/
public enum HintsLevel {
QUALITY(0, "quality"), DEFAULT(1, "default"), SPEED(2, "speed");
private RenderingHints hints;
private String mode;
HintsLevel(int numHint, String mode) {
this.mode = mode;
switch (numHint) {
// QUALITY HINTS
case 0:
hints = new RenderingHints(RenderingHints.KEY_COLOR_RENDERING,
RenderingHints.VALUE_COLOR_RENDER_QUALITY);
hints.add(new RenderingHints(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON));
hints.add(new RenderingHints(RenderingHints.KEY_FRACTIONALMETRICS,
RenderingHints.VALUE_FRACTIONALMETRICS_ON));
hints.add(new RenderingHints(RenderingHints.KEY_ALPHA_INTERPOLATION,
RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY));
hints.add(new RenderingHints(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC));
hints.add(new RenderingHints(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY));
hints.add(new RenderingHints(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON));
hints.add(new RenderingHints(RenderingHints.KEY_STROKE_CONTROL,
RenderingHints.VALUE_STROKE_NORMALIZE));
break;
// DEFAULT HINTS
case 1:
hints = new RenderingHints(RenderingHints.KEY_COLOR_RENDERING,
RenderingHints.VALUE_COLOR_RENDER_DEFAULT);
hints.add(new RenderingHints(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_DEFAULT));
hints.add(new RenderingHints(RenderingHints.KEY_FRACTIONALMETRICS,
RenderingHints.VALUE_FRACTIONALMETRICS_DEFAULT));
hints.add(new RenderingHints(RenderingHints.KEY_ALPHA_INTERPOLATION,
RenderingHints.VALUE_ALPHA_INTERPOLATION_DEFAULT));
hints.add(new RenderingHints(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR));
hints.add(new RenderingHints(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_DEFAULT));
hints.add(new RenderingHints(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_DEFAULT));
hints.add(new RenderingHints(RenderingHints.KEY_STROKE_CONTROL,
RenderingHints.VALUE_STROKE_DEFAULT));
break;
// SPEED HINTS
case 2:
hints = new RenderingHints(RenderingHints.KEY_COLOR_RENDERING,
RenderingHints.VALUE_COLOR_RENDER_SPEED);
hints.add(new RenderingHints(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_OFF));
hints.add(new RenderingHints(RenderingHints.KEY_FRACTIONALMETRICS,
RenderingHints.VALUE_FRACTIONALMETRICS_OFF));
hints.add(new RenderingHints(RenderingHints.KEY_ALPHA_INTERPOLATION,
RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED));
hints.add(new RenderingHints(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR));
hints.add(new RenderingHints(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_SPEED));
hints.add(new RenderingHints(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_OFF));
hints.add(new RenderingHints(RenderingHints.KEY_STROKE_CONTROL,
RenderingHints.VALUE_STROKE_PURE));
break;
}
}
public RenderingHints getRenderingHints() {
return hints;
}
public String getModeName(){
return mode;
}
public static HintsLevel getHintsForMode(String mode) {
if(mode!=null){
if (mode.equalsIgnoreCase(QUALITY.getModeName())) {
return QUALITY;
}else if(mode.equalsIgnoreCase(SPEED.getModeName())){
return SPEED;
}else{
return DEFAULT;
}
}else{
return DEFAULT;
}
}
}
protected WMSTileFuser(TileLayerDispatcher tld, StorageBroker sb, HttpServletRequest servReq)
throws GeoWebCacheException {
this.sb = sb;
String[] keys = { "layers", "format", "srs", "bbox", "width", "height", "transparent",
"bgcolor", "hints" };
Map<String, String> values = ServletUtils.selectedStringsFromMap(servReq.getParameterMap(),
servReq.getCharacterEncoding(), keys);
// TODO Parameter filters?
String layerName = values.get("layers");
layer = tld.getTileLayer(layerName);
gridSubset = layer.getGridSubsetForSRS(SRS.getSRS(values.get("srs")));
outputFormat = (ImageMime) ImageMime.createFromFormat(values.get("format"));
List<MimeType> ml = layer.getMimeTypes();
Iterator<MimeType> iter = ml.iterator();
ImageMime firstMt = null;
if(iter.hasNext()){
firstMt = (ImageMime)iter.next();
}
boolean outputGif = outputFormat.getInternalName().equalsIgnoreCase("gif");
while (iter.hasNext()) {
MimeType mt = iter.next();
if(outputGif){
if (mt.getInternalName().equalsIgnoreCase("gif")) {
this.srcFormat = (ImageMime) mt;
break;
}
}
if (mt.getInternalName().equalsIgnoreCase("png")) {
this.srcFormat = (ImageMime) mt;
}
}
if(srcFormat == null){
srcFormat = firstMt;
}
reqBounds = new BoundingBox(values.get("bbox"));
reqWidth = Integer.valueOf(values.get("width"));
reqHeight = Integer.valueOf(values.get("height"));
fullParameters = layer.getModifiableParameters(servReq.getParameterMap(),
servReq.getCharacterEncoding());
if(values.get("hints")!=null){
hints = HintsLevel.getHintsForMode(values.get("hints")).getRenderingHints();
}
}
protected WMSTileFuser(TileLayer layer, GridSubset gridSubset, BoundingBox bounds, int width,
int height) {
this.sb = null;
this.outputFormat = ImageMime.png;
this.layer = layer;
this.gridSubset = gridSubset;
this.reqBounds = bounds;
this.reqWidth = width;
this.reqHeight = height;
this.fullParameters = Collections.emptyMap();
List<MimeType> ml = layer.getMimeTypes();
Iterator<MimeType> iter = ml.iterator();
ImageMime firstMt = null;
if(iter.hasNext()){
firstMt = (ImageMime)iter.next();
}
while (iter.hasNext()) {
MimeType mt = iter.next();
if (mt.getInternalName().equalsIgnoreCase("png")) {
this.srcFormat = (ImageMime) mt;
break;
}
}
if(srcFormat == null){
srcFormat = firstMt;
}
}
protected void determineSourceResolution() {
xResolution = reqBounds.getWidth() / reqWidth;
yResolution = reqBounds.getHeight() / reqHeight;
double tmpResolution;
// We use the smallest one
if (yResolution < xResolution) {
tmpResolution = yResolution;
} else {
tmpResolution = xResolution;
}
log.debug("x res: " + xResolution + " y res: " + yResolution + " tmpResolution: "
+ tmpResolution);
// Cut ourselves 0.5% slack
double compResolution = 1.005 * tmpResolution;
double[] resArray = gridSubset.getResolutions();
for (srcIdx = 0; srcIdx < resArray.length; srcIdx++) {
srcResolution = resArray[srcIdx];
if (srcResolution < compResolution) {
break;
}
}
if (srcIdx >= resArray.length) {
srcIdx = resArray.length - 1;
}
log.debug("z: " + srcIdx + " , resolution: " + srcResolution + " (" + tmpResolution + ")");
// At worst, we have the best resolution possible
}
protected void determineCanvasLayout() {
// Find the spatial extent of the tiles needed to cover the desired extent
srcRectangle = gridSubset.getCoverageIntersection(srcIdx, reqBounds);
srcBounds = gridSubset.boundsFromRectangle(srcRectangle);
// We now have the complete area, lets figure out our offsets
// Positive means that there is blank space to the first tile,
// negative means we will not use the entire tile
boundOfs.left = srcBounds.getMinX() - reqBounds.getMinX();
boundOfs.bottom = srcBounds.getMinY() - reqBounds.getMinY();
boundOfs.right = reqBounds.getMaxX() - srcBounds.getMaxX();
boundOfs.top = reqBounds.getMaxY() - srcBounds.getMaxY();
canvasSize[0] = (int) Math.round(reqBounds.getWidth() / this.srcResolution);
canvasSize[1] = (int) Math.round(reqBounds.getHeight() / this.srcResolution);
PixelOffsets naiveOfs = new PixelOffsets();
// Calculate the corresponding pixel offsets. We'll stick to sane,
// i.e. bottom left, coordinates at this point
naiveOfs.left = (int) Math.round(boundOfs.left / this.srcResolution);
naiveOfs.bottom = (int) Math.round(boundOfs.bottom / this.srcResolution);
naiveOfs.right = (int) Math.round(boundOfs.right / this.srcResolution);
naiveOfs.top = (int) Math.round(boundOfs.top / this.srcResolution);
// Find the offsets on the opposite sides. This is dependent of how the first two were rounded.
// First, find a tile boundary near the canvas edge, then make sure it's on the correct
// side to match the corresponding boundOfs, then take the modulo of the naive rounding
// based on the boundOfs, then subtract the two and apply the difference to the boundOfs.
int tileWidth = this.gridSubset.getTileWidth();
int tileHeight = this.gridSubset.getTileHeight();
canvOfs.left=naiveOfs.left;
canvOfs.bottom=naiveOfs.bottom;
canvOfs.right = (canvasSize[0]-canvOfs.left)%tileWidth; // Find nearby tile boundary
canvOfs.right = (Integer.signum(naiveOfs.right)*tileWidth+canvOfs.right)%tileWidth; // Ensure same sign as naive calculation
canvOfs.right = canvOfs.right-(naiveOfs.right%tileWidth)+naiveOfs.right; // Find adjustment from naive and apply to naive calculation
canvOfs.top = (canvasSize[1]-canvOfs.bottom)%tileHeight; // Find nearby tile boundary
canvOfs.top = (Integer.signum(naiveOfs.top)*tileHeight+canvOfs.top)%tileHeight; // Ensure same sign as naive calculation
canvOfs.top = canvOfs.top-(naiveOfs.top%tileHeight)+naiveOfs.top; // Find adjustment from naive and apply to naive calculation
//postconditions
assert Math.abs(canvOfs.left-naiveOfs.left)<=1;
assert Math.abs(canvOfs.bottom-naiveOfs.bottom)<=1;
assert Math.abs(canvOfs.right-naiveOfs.right)<=1;
assert Math.abs(canvOfs.top-naiveOfs.top)<=1;
if (log.isDebugEnabled()) {
log.debug("intersection rectangle: " + Arrays.toString(srcRectangle));
log.debug("intersection bounds: " + srcBounds + " (" + reqBounds + ")");
log.debug("Bound offsets: " + Arrays.toString(new double[]{boundOfs.left, boundOfs.bottom, boundOfs.right, boundOfs.top}));
log.debug("Canvas size: " + Arrays.toString(canvasSize) + "(" + reqWidth + ","
+ reqHeight + ")");
log.debug("Canvas offsets: " + Arrays.toString(new int[]{canvOfs.left, canvOfs.bottom, canvOfs.right, canvOfs.top}));
}
}
protected void createCanvas() {
// TODO take bgcolor and transparency from request into account
// should move this into a separate function
Color bgColor = null;
boolean transparent = true;
if (layer instanceof WMSLayer) {
WMSLayer wmsLayer = (WMSLayer) layer;
int[] colorAr = wmsLayer.getBackgroundColor();
if (colorAr != null) {
bgColor = new Color(colorAr[0], colorAr[1], colorAr[2]);
}
transparent = wmsLayer.getTransparent();
}
int canvasType;
if (bgColor == null && transparent
&& (outputFormat.supportsAlphaBit() || outputFormat.supportsAlphaChannel())) {
canvasType = BufferedImage.TYPE_INT_ARGB;
} else {
canvasType = BufferedImage.TYPE_INT_RGB;
if (bgColor == null) {
bgColor = Color.WHITE;
}
}
// Create the actual canvas and graphics object
canvas = new BufferedImage(canvasSize[0], canvasSize[1], canvasType);
gfx = (Graphics2D) canvas.getGraphics();
if (bgColor != null) {
gfx.setColor(bgColor);
gfx.fillRect(0, 0, canvasSize[0], canvasSize[1]);
}
// Hints settings
RenderingHints hintsTemp = HintsLevel.DEFAULT.getRenderingHints();
if(hints!=null){
hintsTemp = hints;
}
gfx.addRenderingHints(hintsTemp);
}
protected void renderCanvas() throws OutsideCoverageException, GeoWebCacheException,
IOException,Exception {
// Now we loop over all the relevant tiles and write them to the canvas,
// Starting at the bottom, moving to the right and up
// Bottom row of tiles, in tile coordinates
long starty = srcRectangle[1];
// gridy is the tile row index
for (long gridy = starty; gridy <= srcRectangle[3]; gridy++) {
int tiley = 0;
int canvasy = (int) (srcRectangle[3] - gridy) * gridSubset.getTileHeight();
int tileHeight = gridSubset.getTileHeight();
if (canvOfs.top > 0) {
// Add padding
canvasy += canvOfs.top;
} else {
// Top tile is cut off
if (gridy == srcRectangle[3]) {
// This one starts at the top, so canvasy remains 0
tileHeight = tileHeight + canvOfs.top;
tiley = -canvOfs.top;
} else {
// Offset that the first tile contributed,
// rather, we subtract what it did not contribute
canvasy += canvOfs.top;
}
}
if (gridy == srcRectangle[1] && canvOfs.bottom < 0) {
// Check whether we only use part of the first tiles (bottom row)
// Offset is negative, slice the bottom off the tile
tileHeight += canvOfs.bottom;
}
long startx = srcRectangle[0];
for (long gridx = startx; gridx <= srcRectangle[2]; gridx++) {
long[] gridLoc = { gridx, gridy, srcIdx };
ConveyorTile tile = new ConveyorTile(sb, layer.getName(), gridSubset.getName(),
gridLoc, srcFormat, fullParameters, null, null);
// Check whether this tile is to be rendered at all
try {
layer.applyRequestFilters(tile);
} catch (RequestFilterException e) {
log.debug(e.getMessage(),e);
continue;
}
layer.getTile(tile);
// Selection of the resource input stream
Resource blob = tile.getBlob();
// Extraction of the image associated with the defined MimeType
String formatName = srcFormat.getMimeType();
BufferedImage tileImg = decoderMap.decode(formatName, blob,
decoderMap.isAggressiveInputStreamSupported(formatName), null);
int tilex = 0;
int canvasx = (int) (gridx - startx) * gridSubset.getTileWidth();
int tileWidth = gridSubset.getTileWidth();
if (canvOfs.left > 0) {
// Add padding
canvasx += canvOfs.left;
} else {
// Leftmost tile is cut off
if (gridx == srcRectangle[0]) {
// This one starts to the left top, so canvasx remains 0
tileWidth = tileWidth + canvOfs.left;
tilex = -canvOfs.left;
} else {
// Offset that the first tile contributed,
// rather, we subtract what it did not contribute
canvasx += canvOfs.left;
}
}
if (gridx == srcRectangle[2] && canvOfs.right < 0) {
// Check whether we only use part of the first tiles (bottom row)
// Offset is negative, slice the bottom off the tile
tileWidth = tileWidth + canvOfs.right;
}
// TODO We should really ensure we can never get here
if (tileWidth == 0 || tileHeight == 0) {
log.debug("tileWidth: " + tileWidth + " tileHeight: " + tileHeight);
continue;
}
// Cut down the tile to the part we want
if (tileWidth != gridSubset.getTileWidth()
|| tileHeight != gridSubset.getTileHeight()) {
log.debug("tileImg.getSubimage(" + tilex + "," + tiley + "," + tileWidth + ","
+ tileHeight + ")");
tileImg = tileImg.getSubimage(tilex, tiley, tileWidth, tileHeight);
}
// Render the tile on the big canvas
log.debug("drawImage(subtile," + canvasx + "," + canvasy + ",null) "
+ Arrays.toString(gridLoc));
gfx.drawImage(tileImg, canvasx, canvasy, null); // imageObserver
}
}
gfx.dispose();
}
protected void scaleRaster() {
if (canvasSize[0] != reqWidth || canvasSize[1] != reqHeight) {
BufferedImage preTransform = canvas;
canvas = new BufferedImage(reqWidth, reqHeight, preTransform.getType());
Graphics2D gfx = canvas.createGraphics();
AffineTransform affineTrans = AffineTransform.getScaleInstance(((double) reqWidth)
/ preTransform.getWidth(), ((double) reqHeight) / preTransform.getHeight());
log.debug("AffineTransform: " + (((double) reqWidth) / preTransform.getWidth()) + ","
+ +(((double) reqHeight) / preTransform.getHeight()));
// Hints settings
RenderingHints hintsTemp = HintsLevel.DEFAULT.getRenderingHints();
if(hints!=null){
hintsTemp = hints;
}
gfx.addRenderingHints(hintsTemp);
gfx.drawRenderedImage(preTransform, affineTrans);
gfx.dispose();
}
}
protected void writeResponse(HttpServletResponse response, RuntimeStats stats)
throws IOException, OutsideCoverageException, GeoWebCacheException,Exception {
determineSourceResolution();
determineCanvasLayout();
createCanvas();
renderCanvas();
scaleRaster();
AccountingOutputStream aos=null;
RenderedImage finalImage =null;
try{
finalImage = canvas;
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(this.outputFormat.getMimeType());
response.setCharacterEncoding("UTF-8");
ServletOutputStream os = response.getOutputStream();
aos = new AccountingOutputStream(os);
// Image encoding with the associated writer
encoderMap.encode(finalImage, outputFormat, aos,
encoderMap.isAggressiveOutputStreamSupported(outputFormat.getMimeType()), null);
log.debug("WMS response size: " + aos.getCount() + "bytes.");
stats.log(aos.getCount(), CacheResult.WMS);
} catch (Exception e) {
log.debug("IOException writing untiled response to client: " + e.getMessage(),e);
// closing the stream
if(aos!=null){
IOUtils.closeQuietly(aos);
}
// releasing Image
if(finalImage!=null){
ImageUtilities.disposePlanarImageChain(PlanarImage.wrapRenderedImage(finalImage));
}
}
}
/**
* Setting of the ApplicationContext associated for extracting the related beans
*
* @param context
* @throws BeansException
*/
public void setApplicationContext(ApplicationContext context) throws BeansException {
applicationContext = context;
decoderMap = applicationContext.getBean(ImageDecoderContainer.class);
encoderMap = applicationContext.getBean(ImageEncoderContainer.class);
}
/**
* Setting of the hints configuration taken from the WMSService
* @param hintsConfig
*/
public void setHintsConfiguration(String hintsConfig) {
if(hints==null){
hints = HintsLevel.getHintsForMode(hintsConfig).getRenderingHints();
}
}
}