/* 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 General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. */
package org.opentripplanner.analyst.core;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.awt.image.IndexColorModel;
import java.awt.image.WritableRaster;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.Map;
import org.geotools.coverage.grid.GridCoverage2D;
import org.geotools.coverage.grid.GridCoverageFactory;
import org.geotools.coverage.grid.GridEnvelope2D;
import org.geotools.coverage.grid.GridGeometry2D;
import org.opentripplanner.analyst.request.RenderRequest;
import org.opentripplanner.analyst.request.TileRequest;
import org.opentripplanner.analyst.parameter.Style;
import org.opentripplanner.routing.spt.ShortestPathTree;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Analyst 8-bit tile format:
* Seconds are converted to minutes.
* Minutes are clamped to +-120
* Unreachable pixels are set to Byte.MIN_VALUE (-128)
* Result is stored in image pixel as a signed byte.
*
* So:
* -119 to +119 are interpreted literally,
* +120 means >= +120,
* -120 means <= -120,
* -128 means "unreachable".
*/
public abstract class Tile {
/* STATIC */
private static final Logger LOG = LoggerFactory.getLogger(Tile.class);
/**
* Creates an interpolated 8-bit color map from the supplied array of color values.
* Each row in the input array is a 5-element array consisting of:
* colorIndex, red, green, blue, alpha
* Color indexes must be in increasing order. Negative indexes will be stored as signed
* bytes, so -1 is 0xFF etc.
*/
private static IndexColorModel interpolatedColorMap(int[][] breaks) {
byte[][] vals = new byte[4][256];
int[] br0 = null;
for (int[] br1 : breaks) {
if (br0 != null) {
int i0 = br0[0];
int i1 = br1[0];
int steps = i1 - i0;
for (int channel = 0; channel < 4; ++channel) {
int v0 = br0[channel+1];
int v1 = br1[channel+1];
float delta = (v1 - v0) / (float) steps;
for (int i = 0; i < steps; i++) {
int v = v0 + (int)(delta * i);
// handle negative indexes
int byte_i = 0x000000FF & (i0 + i);
vals[channel][byte_i] = (byte)v;
}
}
}
br0 = br1;
}
return new IndexColorModel(8, 256, vals[0], vals[1], vals[2], vals[3]);
}
/*
* Pixels are travel times in minutes, stored as signed bytes. This allows us to represent
* times and time differences with absolute values up to 2 hours.
*/
private static final IndexColorModel ICM_SMOOTH_COLOR_15 = interpolatedColorMap( new int[][] {
{0, 0, 0, 0, 0},
{15, 100, 100, 100, 80},
{30, 0, 200, 0, 80},
{45, 0, 0, 200, 80},
{60, 200, 200, 0, 80},
{75, 200, 0, 0, 80},
{90, 200, 0, 200, 50},
{120, 200, 0, 200, 0}
});
private static final IndexColorModel ICM_STEP_COLOR_15 = interpolatedColorMap( new int[][] {
{-128, 100, 100, 100, 200}, // for unreachable places
{0, 100, 100, 100, 0},
{15, 100, 100, 100, 90},
{15, 0, 140, 0, 10},
{30, 0, 140, 0, 90},
{30, 0, 0, 140, 10},
{45, 0, 0, 140, 90},
{45, 140, 140, 0, 10},
{60, 140, 140, 0, 90},
{60, 140, 0, 0, 10},
{75, 140, 0, 0, 90},
{75, 140, 0, 140, 10},
{90, 140, 0, 140, 90},
{90, 100, 100, 100, 50},
{121, 100, 100, 100, 200}
});
private static final IndexColorModel ICM_DIFFERENCE_15 = interpolatedColorMap( new int[][] {
{-128, 0, 0, 0, 0},
{-127, 150, 0, 0, 80},
{-60, 150, 0, 0, 80},
{-15, 150, 150, 0, 80},
{0, 150, 150, 0, 0},
{0, 0, 0, 0, 0},
{15, 0, 0, 150, 80},
{45, 0, 150, 0, 90},
{60, 100, 150, 100, 99},
{127, 50, 150, 50, 99}
});
// SAMENESS bands (northern lights color scheme)
private static final IndexColorModel ICM_SAMENESS_5 = interpolatedColorMap( new int[][] {
{-20, 80, 80, 80, 0},
{-15, 100, 0, 100, 80},
{-10, 0, 0, 150, 80},
{-5, 0, 150, 0, 80},
{ 0, 0, 150, 0, 150},
{ 5, 0, 150, 0, 80},
{ 10, 0, 0, 150, 80},
{ 15, 100, 0, 100, 80},
{ 20, 80, 80, 80, 0},
{-20, 0, 0, 0, 0} // wrap around to hide inaccessible areas
});
private static final IndexColorModel ICM_GRAY_60 = interpolatedColorMap( new int[][] {
{-128, 0, 0, 0, 255}, // black out neg/missing/unreachable
{ 0, 0, 0, 0, 255},
{ 60, 0, 0, 0, 0},
{ 120, 0, 0, 0, 0}
});
private static final IndexColorModel ICM_MASK_60 = interpolatedColorMap( new int[][] {
{ 0, 0, 0, 0, 255},
{60, 0, 0, 0, 0}
});
// int[][] breaks = {
// // break, r, g, b, a
// {0, 0, 150, 0, 20},
// {15, 0, 150, 0, 80},
// {20, 0, 0, 50, 80},
// {30, 0, 0, 150, 80},
// {40, 50, 50, 0, 80},
// {60, 150, 150, 0, 80},
// {70, 150, 50, 0, 80},
// {90, 150, 0, 0, 80},
// {255, 150, 0, 150, 0}
//};
//int[][] breaks = {
// // break, r, g, b, a
// {0, 100, 100, 100, 80},
// {15, 100, 100, 100, 80},
// {15, 0, 150, 0, 80},
// {30, 0, 150, 0, 80},
// {30, 0, 0, 150, 80},
// {45, 0, 0, 150, 80},
// {45, 150, 150, 0, 80},
// {60, 150, 150, 0, 80},
// {60, 150, 0, 0, 80},
// {75, 150, 0, 0, 80},
// {75, 0, 100, 100, 80},
// {255, 0, 100, 100, 0}
// };
public static final Map<Style, IndexColorModel> modelsByStyle;
static {
modelsByStyle = new EnumMap<Style, IndexColorModel>(Style.class);
modelsByStyle.put(Style.COLOR30, ICM_STEP_COLOR_15);
modelsByStyle.put(Style.DIFFERENCE, ICM_DIFFERENCE_15);
modelsByStyle.put(Style.TRANSPARENT, ICM_GRAY_60);
modelsByStyle.put(Style.MASK, ICM_MASK_60);
modelsByStyle.put(Style.BOARDINGS, buildBoardingColorMap());
}
/* INSTANCE */
final GridGeometry2D gg;
final int width, height;
Tile(TileRequest req) {
GridEnvelope2D gridEnv = new GridEnvelope2D(0, 0, req.width, req.height);
this.gg = new GridGeometry2D(gridEnv, (org.opengis.geometry.Envelope)(req.bbox));
// TODO: check that gg intersects graph area
LOG.debug("preparing tile for {}", gg.getEnvelope2D());
// Envelope2D worldEnv = gg.getEnvelope2D();
this.width = gridEnv.width;
this.height = gridEnv.height;
}
private static IndexColorModel buildOldDefaultColorMap() {
Color[] palette = new Color[256];
final int ALPHA = 0x60FFFFFF; // ARGB
for (int i = 0; i < 28; i++) {
// Note: HSB = Hue / Saturation / Brightness
palette[i + 00] = new Color(ALPHA & Color.HSBtoRGB(0.333f, i * 0.037f, 0.8f), true); // Green
palette[i + 30] = new Color(ALPHA & Color.HSBtoRGB(0.666f, i * 0.037f, 0.8f), true); // Blue
palette[i + 60] = new Color(ALPHA & Color.HSBtoRGB(0.144f, i * 0.037f, 0.8f), true); // Yellow
palette[i + 90] = new Color(ALPHA & Color.HSBtoRGB(0.000f, i * 0.037f, 0.8f), true); // Red
palette[i + 120] = new Color(ALPHA & Color.HSBtoRGB(0.000f, 0.000f, (29 - i) * 0.0172f), true); // Black
}
for (int i = 28; i < 30; i++) {
palette[i + 00] = new Color(ALPHA & Color.HSBtoRGB(0.333f, (30 - i) * 0.333f, 0.8f), true); // Green
palette[i + 30] = new Color(ALPHA & Color.HSBtoRGB(0.666f, (30 - i) * 0.333f, 0.8f), true); // Blue
palette[i + 60] = new Color(ALPHA & Color.HSBtoRGB(0.144f, (30 - i) * 0.333f, 0.8f), true); // Yellow
palette[i + 90] = new Color(ALPHA & Color.HSBtoRGB(0.000f, (30 - i) * 0.333f, 0.8f), true); // Red
palette[i + 120] = new Color(ALPHA & Color.HSBtoRGB(0.000f, 0.000f, (29 - i) * 0.0172f), true); // Black
}
for (int i = 150; i < palette.length; i++) {
palette[i] = new Color(0x00000000, true);
}
byte[] r = new byte[256];
byte[] g = new byte[256];
byte[] b = new byte[256];
byte[] a = new byte[256];
for (int i = 0; i < palette.length; i++) {
r[i] = (byte)palette[i].getRed();
g[i] = (byte)palette[i].getGreen();
b[i] = (byte)palette[i].getBlue();
a[i] = (byte)palette[i].getAlpha();
}
return new IndexColorModel(8, 256, r, g, b, a);
}
private static IndexColorModel buildBoardingColorMap() {
byte[] r = new byte[256];
byte[] g = new byte[256];
byte[] b = new byte[256];
byte[] a = new byte[256];
Arrays.fill(a, (byte) 80);
g[0] = (byte) 255;
b[1] = (byte) 255;
r[2] = (byte) 255;
g[2] = (byte) 255;
r[3] = (byte) 255;
a[255] = 0;
return new IndexColorModel(8, 256, r, g, b, a);
}
protected BufferedImage getEmptyImage(Style style) {
IndexColorModel colorModel = modelsByStyle.get(style);
if (colorModel == null)
return new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
else
return new BufferedImage(width, height, BufferedImage.TYPE_BYTE_INDEXED, colorModel);
}
final byte UNREACHABLE = Byte.MIN_VALUE;
public BufferedImage generateImage(ShortestPathTree spt, RenderRequest renderRequest) {
long t0 = System.currentTimeMillis();
BufferedImage image = getEmptyImage(renderRequest.style);
byte[] imagePixelData = ((DataBufferByte)image.getRaster().getDataBuffer()).getData();
int i = 0;
for (Sample s : getSamples()) {
byte pixel;
if (s != null) {
if (renderRequest.style == Style.BOARDINGS) {
pixel = s.evalBoardings(spt);
} else {
long t = s.eval(spt); // renderRequest.style
if (t == Long.MAX_VALUE)
pixel = UNREACHABLE;
else {
t /= 60;
if (t < -120)
t = -120;
else if (t > 120)
t = 120;
pixel = (byte) t;
}
}
} else {
pixel = UNREACHABLE;
}
imagePixelData[i] = pixel;
i++;
}
long t1 = System.currentTimeMillis();
LOG.debug("filled in tile image from SPT in {}msec", t1 - t0);
return image;
}
public BufferedImage linearCombination(
double k1, ShortestPathTree spt1,
double k2, ShortestPathTree spt2,
double intercept, RenderRequest renderRequest) {
long t0 = System.currentTimeMillis();
BufferedImage image = getEmptyImage(renderRequest.style);
byte[] imagePixelData = ((DataBufferByte)image.getRaster().getDataBuffer()).getData();
int i = 0;
for (Sample s : getSamples()) {
byte pixel = UNREACHABLE;
if (s != null) {
long t1 = s.eval(spt1);
long t2 = s.eval(spt2);
if (t1 != Long.MAX_VALUE && t2 != Long.MAX_VALUE) {
double t = (k1 * t1 + k2 * t2) / 60 + intercept;
if (t < -120)
t = -120;
else if (t > 120)
t = 120;
pixel = (byte) t;
}
}
imagePixelData[i] = pixel;
i++;
}
long t1 = System.currentTimeMillis();
LOG.debug("filled in tile image from SPT in {}msec", t1 - t0);
return image;
}
public GridCoverage2D getGridCoverage2D(BufferedImage image) {
GridCoverage2D gridCoverage = new GridCoverageFactory()
.create("isochrone", image, gg.getEnvelope2D());
return gridCoverage;
}
public abstract Sample[] getSamples();
public static BufferedImage getLegend(Style style, int width, int height) {
final int NBANDS = 150;
final int LABEL_SPACING = 30;
IndexColorModel model = modelsByStyle.get(style);
if (width < 140 || width > 2000)
width = 140;
if (height < 25 || height > 2000)
height = 25;
if (model == null)
return null;
WritableRaster raster = model.createCompatibleWritableRaster(width, height);
byte[] pixels = ((DataBufferByte) raster.getDataBuffer()).getData();
for (int row = 0; row < height; row++)
for (int col = 0; col < width; col++)
pixels[row * width + col] = (byte) (col * NBANDS / width);
BufferedImage legend = model.convertToIntDiscrete(raster, false);
Graphics2D gr = legend.createGraphics();
gr.setColor(new Color(0));
gr.drawString("travel time (minutes)", 0, 10);
float scale = width / (float) NBANDS;
for (int i = 0; i < NBANDS; i += LABEL_SPACING)
gr.drawString(Integer.toString(i), i * scale, height);
return legend;
}
}