Commit 5decce3f authored by Andrea Aime's avatar Andrea Aime
Browse files

Backporting the pngj module to the 2.3.x series

parent fbc784ac
<?xml version="1.0" encoding="ISO-8859-1"?>
<!--
Copyright (c) 2001 - 2013 OpenPlans - www.openplans.org. All rights reserved.
This code is licensed under the GPL 2.0 license, available at the root
application directory.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.geoserver</groupId>
<artifactId>community</artifactId>
<version>2.3-SNAPSHOT</version>
</parent>
<groupId>org.geoserver.community</groupId>
<artifactId>png</artifactId>
<packaging>jar</packaging>
<name>Fast pure java PNG output format</name>
<dependencies>
<dependency>
<groupId>org.geoserver</groupId>
<artifactId>wms</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>ar.com.hjg</groupId>
<artifactId>pngj</artifactId>
<version>2.0.1</version>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>org.geoserver</groupId>
<artifactId>main</artifactId>
<version>${project.version}</version>
<classifier>tests</classifier>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.geoserver</groupId>
<artifactId>wms</artifactId>
<version>${project.version}</version>
<classifier>tests</classifier>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.mockrunner</groupId>
<artifactId>mockrunner</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.easymock</groupId>
<artifactId>easymockclassextension</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>xmlunit</groupId>
<artifactId>xmlunit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.carrotsearch</groupId>
<artifactId>junit-benchmarks</artifactId>
<version>0.7.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.3.172</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<testResources>
<testResource>
<directory>src/test/resources</directory>
</testResource>
</testResources>
</build>
</project>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
<bean id="PNGJOutputExclusionFilter" class="org.geoserver.platform.NameExclusionFilter">
<property name="beanId" value="PNGMapResponse" />
</bean>
<bean id="PNGJMapResponse" class="org.geoserver.map.png.PNGJMapResponse">
<constructor-arg ref="wms" />
</bean>
</beans>
/* Copyright (c) 2013 OpenPlans - www.openplans.org. All rights reserved.
* This code is licensed under the GPL 2.0 license, available at the root
* application directory.
*/
package org.geoserver.map.png;
import java.awt.image.ColorModel;
import java.awt.image.IndexColorModel;
import java.awt.image.RenderedImage;
import java.io.IOException;
import java.io.OutputStream;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.geoserver.map.png.providers.PNGJWriter;
import org.geoserver.map.png.providers.ScanlineProvider;
import org.geoserver.map.png.providers.ScanlineProviderFactory;
import org.geoserver.platform.Operation;
import org.geoserver.platform.ServiceException;
import org.geoserver.wms.GetMapRequest;
import org.geoserver.wms.MapProducerCapabilities;
import org.geoserver.wms.RasterCleaner;
import org.geoserver.wms.WMS;
import org.geoserver.wms.WMSMapContent;
import org.geoserver.wms.map.PNGMapResponse;
import org.geoserver.wms.map.RenderedImageMapResponse;
import org.geoserver.wms.map.quantize.ColorIndexerDescriptor;
import org.geotools.image.ImageWorker;
import org.geotools.map.Layer;
import org.geotools.styling.Style;
import org.geotools.util.logging.Logging;
import ar.com.hjg.pngj.FilterType;
import ar.com.hjg.pngj.ImageInfo;
import ar.com.hjg.pngj.PngWriter;
import ar.com.hjg.pngj.chunks.PngChunkPLTE;
import ar.com.hjg.pngj.chunks.PngChunkTRNS;
/**
* A PNG encoder based on PNGJ (https://code.google.com/p/pngj), a low level PNG library, coupled
* with highly optimized data extractors meant to provide the least amount of data copies between
* the input image and the output PNG stream
*
* @author Andrea Aime - GeoSolutions
*/
public class PNGJMapResponse extends RenderedImageMapResponse {
/** Logger */
private static final Logger LOGGER = Logging.getLogger(PNGMapResponse.class);
private static final String MIME_TYPE = "image/png";
private static final String MIME_TYPE_8BIT = "image/png; mode=8bit";
private static final String[] OUTPUT_FORMATS = { MIME_TYPE, MIME_TYPE_8BIT, "image/png8" };
static {
ColorIndexerDescriptor.register();
}
/**
* Default capabilities for PNG format.
*
* <p>
* <ol>
* <li>tiled = supported</li>
* <li>multipleValues = unsupported</li>
* <li>paletteSupported = supported</li>
* <li>transparency = supported</li>
* </ol>
*/
private static MapProducerCapabilities CAPABILITIES = new MapProducerCapabilities(true, false,
true, true, null);
/**
* @param format the format name as to be reported in the capabilities document
* @param wms
*/
public PNGJMapResponse(WMS wms) {
super(OUTPUT_FORMATS, wms);
LOGGER.log(Level.INFO, "Activating PNGJ based PNG encoder");
}
@Override
public String getMimeType(Object value, Operation operation) throws ServiceException {
GetMapRequest request = (GetMapRequest) operation.getParameters()[0];
if (request.getFormat().contains("8")) {
return MIME_TYPE_8BIT;
} else {
return MIME_TYPE;
}
}
/**
* Transforms the rendered image into the appropriate format, streaming to the output stream.
*
* @see RasterMapOutputFormat#formatImageOutputStream(RenderedImage, OutputStream)
*/
public void formatImageOutputStream(RenderedImage image, OutputStream outStream,
WMSMapContent mapContent) throws ServiceException, IOException {
// /////////////////////////////////////////////////////////////////
//
// Reformatting this image for png
//
// /////////////////////////////////////////////////////////////////
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER.fine("Writing png image ...");
}
// we could have a 16 bit paletted image, that PNG cannot handle
if(image.getColorModel() instanceof IndexColorModel) {
IndexColorModel icm = (IndexColorModel) image.getColorModel();
if(icm.getMapSize() > 256) {
if(LOGGER.isLoggable(Level.FINER)){
LOGGER.fine("Forcing input image to be compatible with PNG: Palette with > 256 color is not supported.");
}
ImageWorker iw = new ImageWorker(image);
iw.rescaleToBytes();
image = iw.getRenderedImage();
}
}
// check to see if we have to see a translucent or bitmask quantizer
image = applyPalette(image, mapContent, "image/png8", true);
float quality = (100 - wms.getPngCompression()) / 100.0f;
image = new PNGJWriter().writePNG(image, outStream, quality, mapContent);
RasterCleaner.addImage(image);
}
/**
* SUB filtering is useful for raster images with "high" variation, otherwise we go for NONE,
* empirically it provides better compression at lower effort
*
* @param mapContent
* @return
*/
private FilterType getFilterType(WMSMapContent mapContent) {
RasterSymbolizerVisitor visitor = new RasterSymbolizerVisitor();
for (Layer layer : mapContent.layers()) {
// check if the style has a raster symbolizer, don't trust the layer type as
// we don't know in advance if there is a rendering transformation
// WMS cascading is a ugly case, we might be cascading a map that is vector, but
// we don't get to know
Style style = layer.getStyle();
if(style != null) {
style.accept(visitor);
if(visitor.highChangeRasterSymbolizer) {
return FilterType.FILTER_SUB;
}
}
}
return FilterType.FILTER_NONE;
}
public void writePNG(RenderedImage image, OutputStream outStream, int level, FilterType filterType) {
ScanlineProvider scanlines = ScanlineProviderFactory.getProvider(image);
// encode using the PNGJ library and the GeoServer own scaline providers
ColorModel colorModel = image.getColorModel();
boolean indexed = colorModel instanceof IndexColorModel;
int numColorComponents = colorModel.getNumColorComponents();
boolean grayscale = !indexed && numColorComponents < 3;
byte bitDepth = scanlines.getBitDepth();
boolean hasAlpha = !indexed && colorModel.hasAlpha();
ImageInfo ii = new ImageInfo(image.getWidth(), image.getHeight(), bitDepth, hasAlpha,
grayscale, indexed);
PngWriter pw = new PngWriter(outStream, ii);
pw.setShouldCloseStream(false);
try {
pw.setCompLevel(level);
pw.setFilterType(filterType);
if (indexed) {
IndexColorModel icm = (IndexColorModel) colorModel;
PngChunkPLTE palette = pw.getMetadata().createPLTEChunk();
int ncolors = icm.getMapSize();
palette.setNentries(ncolors);
for (int i = 0; i < ncolors; i++) {
final int red = icm.getRed(i);
final int green = icm.getGreen(i);
final int blue = icm.getBlue(i);
palette.setEntry(i, red, green, blue);
}
if (icm.hasAlpha()) {
PngChunkTRNS transparent = new PngChunkTRNS(ii);
int[] alpha = new int[ncolors];
for (int i = 0; i < ncolors; i++) {
final int a = icm.getAlpha(i);
alpha[i] = a;
}
transparent.setPalletteAlpha(alpha);
pw.getChunksList().queue(transparent);
}
}
// write out the actual image lines
for (int row = 0; row < image.getHeight(); row++) {
pw.writeRow(scanlines);
}
pw.end();
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Failed to encode the PNG", e);
throw new ServiceException(e);
} finally {
pw.close();
}
}
@Override
public MapProducerCapabilities getCapabilities(String outputFormat) {
return CAPABILITIES;
}
}
package org.geoserver.map.png;
import org.geotools.styling.AbstractStyleVisitor;
import org.geotools.styling.ColorMap;
/**
* Check if the style contains a "high change" raster symbolizer, that is, one that generates
* a continuous set of values for which SUB filtering provides better results
*
* @author Andrea Aime - GeoSolutions
*/
class RasterSymbolizerVisitor extends AbstractStyleVisitor {
boolean highChangeRasterSymbolizer;
public void visit(org.geotools.styling.RasterSymbolizer raster) {
if(raster.getColorMap() == null) {
highChangeRasterSymbolizer = true;
return;
}
int cmType = raster.getColorMap().getType();
if(cmType != ColorMap.TYPE_INTERVALS && cmType != ColorMap.TYPE_VALUES) {
highChangeRasterSymbolizer = true;
}
}
}
/* Copyright (c) 2013 OpenPlans - www.openplans.org. All rights reserved.
* This code is licensed under the GPL 2.0 license, available at the root
* application directory.
*/
package org.geoserver.map.png.providers;
import java.awt.image.IndexColorModel;
import java.awt.image.Raster;
/**
* Base class providing common traits to all scanline providers
*
* @author Andrea Aime - GeoSolutions
*/
public abstract class AbstractScanlineProvider implements ScanlineProvider {
protected final int width;
protected final int height;
protected final int scanlineLength;
protected final ScanlineCursor cursor;
protected final IndexColorModel palette;
protected final byte bitDepth;
protected int currentRow = 0;
public AbstractScanlineProvider(Raster raster, int bitDepth, int scanlineLength) {
this(raster, (byte) bitDepth, scanlineLength, null);
}
public AbstractScanlineProvider(Raster raster, int bitDepth, int scanlineLength, IndexColorModel palette) {
this(raster, (byte) bitDepth, scanlineLength, palette);
}
protected AbstractScanlineProvider(Raster raster, byte bitDepth, int scanlineLength, IndexColorModel palette) {
this.width = raster.getWidth();
this.height = raster.getHeight();
this.bitDepth = bitDepth;
this.palette = palette;
this.cursor = new ScanlineCursor(raster);
this.scanlineLength = scanlineLength;
}
@Override
public final int getWidth() {
return width;
}
@Override
public final int getHeight() {
return height;
}
@Override
public final byte getBitDepth() {
return bitDepth;
}
@Override
public final IndexColorModel getPalette() {
return palette;
}
@Override
public final int getScanlineLength() {
return scanlineLength;
}
public void readFromPngRaw(byte[] raw, int len, int offset, int step) {
throw new UnsupportedOperationException("This bridge works write only");
}
public void endReadFromPngRaw() {
throw new UnsupportedOperationException("This bridge works write only");
}
public void writeToPngRaw(byte[] raw) {
// PNGJ stores in the first byte the filter type
this.next(raw, 1, raw.length - 1);
}
}
/* Copyright (c) 2013 OpenPlans - www.openplans.org. All rights reserved.
* This code is licensed under the GPL 2.0 license, available at the root
* application directory.
*/
package org.geoserver.map.png.providers;
import java.awt.image.ColorModel;
import java.awt.image.IndexColorModel;
import java.awt.image.RenderedImage;
import java.io.OutputStream;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.geoserver.platform.ServiceException;
import org.geoserver.wms.WMSMapContent;
import org.geotools.image.ImageWorker;
import org.geotools.map.Layer;
import org.geotools.styling.AbstractStyleVisitor;
import org.geotools.styling.ColorMap;
import org.geotools.styling.Style;
import org.geotools.util.logging.Logging;
import ar.com.hjg.pngj.FilterType;
import ar.com.hjg.pngj.ImageInfo;
import ar.com.hjg.pngj.PngWriter;
import ar.com.hjg.pngj.chunks.PngChunkPLTE;
import ar.com.hjg.pngj.chunks.PngChunkTRNS;
/**
* Encodes the image in PNG using the PNGJ library
*
* @author Andrea Aime - GeoSolutions
*/
public class PNGJWriter {
private static final Logger LOGGER = Logging.getLogger(PNGJWriter.class);
public RenderedImage writePNG(RenderedImage image, OutputStream outStream, float quality,
WMSMapContent mapContent) {
// compute the compression level similarly to what the Clib code does
int level = Math.round(9 * (1f - quality));
// what kind of scaline filtering are we going to use?
FilterType filterType = getFilterType(mapContent);
return writePNG(image, outStream, level, filterType);
}
RenderedImage writePNG(RenderedImage image, OutputStream outStream, int level,
FilterType filterType) {
// get the optimal scanline provider for this image
RenderedImage original = image;
ScanlineProvider scanlines = ScanlineProviderFactory.getProvider(image);
if(scanlines == null) {
image = new ImageWorker(image).rescaleToBytes().forceComponentColorModel().getRenderedImage();
scanlines = ScanlineProviderFactory.getProvider(image);
}
if(scanlines == null) {
throw new IllegalArgumentException("Could not find a scanline extractor for " + original);
}
// encode using the PNGJ library and the GeoServer own scanline providers
ColorModel colorModel = image.getColorModel();
boolean indexed = colorModel instanceof IndexColorModel;
ImageInfo ii = getImageInfo(image, scanlines, colorModel, indexed);
PngWriter pw = new PngWriter(outStream, ii);
pw.setShouldCloseStream(false);
try {
pw.setCompLevel(level);
pw.setFilterType(filterType);
if (indexed) {
IndexColorModel icm = (IndexColorModel) colorModel;
PngChunkPLTE palette = pw.getMetadata().createPLTEChunk();
int ncolors = icm.getMapSize();
palette.setNentries(ncolors);
for (int i = 0; i < ncolors; i++) {
final int red = icm.getRed(i);
final int green = icm.getGreen(i);
final int blue = icm.getBlue(i);
palette.setEntry(i, red, green, blue);
}
if (icm.hasAlpha()) {
PngChunkTRNS transparent = new PngChunkTRNS(ii);
int[] alpha = new int[ncolors];
for (int i = 0; i < ncolors; i++) {
final int a = icm.getAlpha(i);
alpha[i] = a;
}
transparent.setPalletteAlpha(alpha);
pw.getChunksList().queue(transparent);
}
}
// write out the actual image lines
for (int row = 0; row < image.getHeight(); row++) {
pw.writeRow(scanlines);
}
pw.end();
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Failed to encode the PNG", e);
throw new ServiceException(e);
} finally {
pw.close();
}
return image;
}
private ImageInfo getImageInfo(RenderedImage image, ScanlineProvider scanlines,
ColorModel colorModel, boolean indexed) {
int numColorComponents = colorModel.getNumColorComponents();
boolean grayscale = !indexed && numColorComponents < 3;
byte bitDepth = scanlines.getBitDepth();
boolean hasAlpha = !indexed && colorModel.hasAlpha();
ImageInfo ii = new ImageInfo(image.getWidth(), image.getHeight(), bitDepth, hasAlpha,
grayscale, indexed);
return ii;
}
/**
* SUB filtering is useful for raster images with "high" variation, otherwise we go for NONE,
* empirically it provides better compression at lower effort
*
* @param mapContent
* @return
*/
private FilterType getFilterType(WMSMapContent mapContent) {
RasterSymbolizerVisitor visitor = new RasterSymbolizerVisitor();
for (Layer layer : mapContent.layers()) {
// check if the style has a raster symbolizer, don't trust the layer type as
// we don't know in advance if there is a rendering transformation
// WMS cascading is a ugly case, we might be cascading a map that is vector, but
// we don't get to know
Style style = layer.getStyle();
if (style != null) {
style.accept(visitor);
if (visitor.highChangeRasterSymbolizer) {
return FilterType.FILTER_SUB;
}
}