Prerequisites
In this tutorial, it is assumed that you are familiar with the LuciadLightspeed concepts introduced in the Create your first Lightspeed application tutorial:
-
Lightspeed views:
ILspView
-
Lightspeed layers:
ILspLayer
-
Model and model decoders:
ILcdModel
andILcdModelDecoder
and with the services mechanism, as introduced in Introduction to the services mechanism.
Goal
In this tutorial, we are going to add support for our own, custom raster format with elevation data. You can use a similar approach for imagery raster data.
Most of the time, it suffices to use one of the many model decoders in the LuciadLightspeed API for raster data. For example, to decode DTED or GeoTIFF data, you can use a model decoder from the LuciadLightspeed API.
However, sometimes you have data defined in a custom format for which LuciadLightspeed does not provide a default model decoder. In that case, you need to create the model and the decoder yourself.
To create a model decoder, you need to choose or define LuciadLightspeed classes for:
-
The domain objects (the model data)
-
The model reference
-
The model descriptor
-
The model
In this tutorial, we are going to perform all those steps.
Once we have the model decoder, we use the standard LuciadLightspeed API classes to visualize the data.
What is raster data?
Raster data is typically used to model:
-
Imagery data: for example, photos taken from a satellite or airplane.
-
Elevation data
Raster data is typically provided as a regular grid of cells, in which:
-
Each cell has the same size, cells of 1 degree by 1 degree, for example.
-
Each cell contains pixel values. Depending on the type of data, a pixel value represents different things:
-
In the case of imagery data, a pixel value is typically a color.
-
In elevation data, the pixel values represent heights.
-
This grid of cells forms a rectangular, axis-aligned area.
Raster data sets are often very large in size, but the mapping application never has to load all the data because the data set is divided into cells. When the application user is looking at a certain map area, only the cells in the data set overlapping with that area must be loaded.
A second precaution to prevent loading all data at once is the organization of raster data sets into multiple levels-of-detail. Without them, the mapping application would need to load more cells if a map user zooms out and looks at a larger area. If multiple levels-of-detail are available in the data, however, the mapping application can load less detailed cells, and therefore less data, when the user zooms out on the map.
The custom format
For this tutorial, we are going to generate the raster values from code. We are not going to read them from some persistent
storage system, like a file.
We will be using the classes from the java.awt.image
package to generate the raster values.
This allows us to focus on how to model your raster data with the LuciadLightspeed API, and skip the creation of code to read and consume data from some binary, made-up format.
Our custom format:
-
Represents elevation data: each pixel in the data represents a height.
-
Uses a 2x3 grid of cells. Each cell has a geographical size of 1x1 degrees.
-
Is not multi-leveled. There are no distinct levels-of-detail.
Modeling the domain objects
The
ALcdImage
class is the base class for all raster domain objects.
It represents a geographically bounded, pixel-oriented data source.
Associated with each ALcdImage
is an ALcdImage.Configuration
object that describes the properties and structure of the image data.
In our case, the Configuration
describes these properties:
-
The data is elevation data.
-
The measurement unit in which the elevation values are expressed.
For example, if the pixel value is 5, that can mean 5 meters, 5 feet, and so on. The
Configuration
clarifies what the unit is. -
The encoding of the values.
Are the values expressed as shorts, integers, or something else ? Are the values signed or not ?
-
The
NO_DATA
value.If your elevation data was captured with a technique that works on land but not on water, your data set may contain areas where the elevation could not be measured, on a lake for example. Because a raster is always a rectangular area, you cannot remove the lake from your data.
On raster data, you have the option to identify areas without data by means of a specific value. You can indicate in the metadata that that specific value must be interpreted as having "no data".
Creating the model decoder
Creating a model decoder comes down to implementing the ILcdModelDecoder
interface.
We start by implementing the getDisplayName
method.
This method should return a human-readable name for the format that the model decoder can decode.
@Override
public String getDisplayName() {
return "Custom Raster Decoder";
}
A model decoder should also indicate which sources it can handle. This check needs to be a fast one. A typical implementation checks the extension. However, because this model decoder will generate the data, we use a fake check that compares with a fixed string.
@Override
public boolean canDecodeSource(String aSourceName) {
//Normally you would do a check on aSourceName,
//for example checking the file extension.
//Since this decoder generates the model, we only accept a fake path
return Objects.equals(aSourceName, "Memory");
}
The last method to implement is the method which performs the actual decoding. In this method, we:
-
Create an
ILcdModel
and configure it with:-
An
ILcdModelReference
: the georeference in which the location and bounds of each raster cell are expressed. In the case of our fake data, this is a WGS84 reference. -
An
ILcdImageModelDescriptor
: we will useALcdImage
instances as domain objects, so ourILcdModelDescriptor
must be anILcdImageModelDescriptor
instance.
-
-
Create the semantics of the raster data, describing its structure and properties.
-
Create the different tiles, and group them in an
ALcdImage
extension that forms our domain object. -
Populate the model with our
ALcdImage
. -
Return the model.
In code, this looks like:
@Override
public ILcdModel decode(String aSourceName) throws IOException {
//Sanity check to see whether we can decode the data
if (!canDecodeSource(aSourceName)) {
throw new IOException("Cannot decode " + aSourceName);
}
//Create the model reference and descriptor
ILcdModelReference modelReference = createModelReference();
ILcdImageModelDescriptor modelDescriptor = createModelDescriptor(aSourceName);
//Create the bounds and semantics of our dataset
ILcdBounds datasetBounds = createSpatialBounds();
List<ALcdBandSemantics> semantics = createSemantics();
//Create the domain object
ALcdImage mosaic = createDomainObject(modelReference, datasetBounds, semantics);
//Create the model and populate it with the ALcdImage domain object
TLcdVectorModel model = new TLcdVectorModel(modelReference, modelDescriptor);
model.addElement(mosaic, ILcdFireEventMode.NO_EVENT);
//Return the created model
return model;
}
For the model reference, we use a hard-coded WGS84 reference.
When you are decoding data from a real format, you can typically obtain the model reference by decoding an external .prj
or .epsg
file.
If your custom data has such a file, you can use our ILcdModelReferenceDecoder
implementation to decode that file.
private ILcdModelReference createModelReference() {
return new TLcdGeodeticReference();//This is a WGS-84 reference
}
For the model descriptor, we use the default implementation of the ILcdImageModelDescriptor
interface:
private ILcdImageModelDescriptor createModelDescriptor(String aSourceName) {
return new TLcdImageModelDescriptor(
aSourceName,
"custom_mosaic", //Typename: identifier of the format
"Custom mosaic"//Display name for the model
);
}
For the bounds of our fake data set, we use hard-coded bounds expressed in the WGS84 reference. When you are working with real data, this information is typically derived from an external file, such as a TFW file, or from the raster data itself; typically from a header section in the file.
private ILcdBounds createSpatialBounds() {
return new TLcdLonLatBounds(4, 51, 2, 3);
}
For our actual data:
-
We generate the raster tiles or cells. A single cell or tile is represented by an
ALcdImage
.ALcdImage
instances cannot be constructed directly, but are built with aTLcdImageBuilder
.-
In this case, all our tiles represent data with the same properties and structure. Therefore, we can use the same semantics for each tile. Semantics are modeled with the
ALcdBandSemantics
class.Because the data is elevation data and each pixel represents an elevation measurement, we use the
ALcdBandMeasurementSemantics
class (an extension ofALcdBandSemantics
). -
The semantics of an
ALcdImage
correspond to the semantics for an image band. For example, an RGB image has 3 bands, and each band can have different semantics.Our custom elevation data has a single band containing the measurement values.
-
-
We group all raster tiles into an
ALcdImageMosaic
. This class represents a grid of tiles.
The tile creation comes down to collecting all the information we have about the tile on the builder, and letting the builder
create the ALcdImage
:
private ALcdBasicImage createTile(RenderedImage aTileData,
ILcdBounds aTileBounds,
ILcdModelReference aModelReference,
List<ALcdBandSemantics> aTileSemantics) {
return TLcdImageBuilder.newBuilder()
.image(aTileData)
.semantics(aTileSemantics)
.bounds(aTileBounds)
.imageReference(aModelReference)
.buildBasicImage();
}
The band semantics are also created using a builder:
private List<ALcdBandSemantics> createSemantics() {
// The 'unknown value' indicates the pixel value that represents that no data is available
// at that point. We use the same value as DTED here.
int unknownValue = -32767;
//The elevation values are expressed in metres.
//Here we create a unit of measure that expresses that
ILcdISO19103UnitOfMeasure uom =
TLcdUnitOfMeasureFactory.deriveUnitOfMeasure(TLcdAltitudeUnit.METRE,
TLcdISO19103MeasureTypeCodeExtension.TERRAIN_HEIGHT);
// Use the TLcdBandMeasurementSemanticsBuilder to express that the pixel values are measurements
ALcdBandMeasurementSemantics semantics = TLcdBandMeasurementSemanticsBuilder
.newBuilder()
.unitOfMeasure(uom)//here we indicate the unit for the measurements
.dataType(ALcdBandSemantics.DataType.SIGNED_SHORT)
.noDataValue(unknownValue)
.build();
//Our data has only a single band, so return a singleton collection
return Collections.singletonList(semantics);
}
This code is sufficient to create a single tile or cell. We want to create a grid of cells, and combine them all in a mosaic.
private ALcdImage createDomainObject(ILcdModelReference aModelReference,
ILcdBounds aDataSetBounds,
List<ALcdBandSemantics> aSemantics) {
// Here we create the mosaic containing the image tiles.
// This sample generates a 2 x 3 grid of images. Each tile corresponds to a 1 x 1 degree area.
// The tile grid that should be created depends on the underlying data format. Raster datasets
// in some formats, like for instance DTED, consist of many individual files. A decoder will
// then typically create a tile object per file.
// Simpler formats may only consist of a single file and will only require a single tile object
int tileColumns = 2;
int tileRows = 3;
double tileWidth = aDataSetBounds.getWidth() / tileColumns;
double tileHeight = aDataSetBounds.getHeight() / tileRows;
TLcdImageMosaicBuilder mosaicBuilder = TLcdImageMosaicBuilder.newBuilder();
for (int y = 0; y < tileRows; y++) {
for (int x = 0; x < tileColumns; x++) {
//Calculate the bounds of the tile
//Since it is a regular grid, it can be dereived from the total dataset bounds
ILcd2DEditableBounds tileBounds = new TLcdLonLatBounds(
aDataSetBounds.getLocation().getX() + tileWidth * x,
aDataSetBounds.getLocation().getY() + tileHeight * y,
tileWidth,
tileHeight
);
//Generate fake elevation data
//Normally this data would be read from e.g. a file
RenderedImage tileData = generateData();
//Convert the java.awt.image to an ALcdImage representing the tile
ALcdBasicImage tile = createTile(tileData, tileBounds, aModelReference, aSemantics);
//Add the tile to the mosaic, and indicate where it is positioned
mosaicBuilder.tile(tile, x, y);
}
}
//Configure the required parameters on the mosaic builder and finally build the mosaic.
return mosaicBuilder.bounds(aDataSetBounds)
.semantics(aSemantics)
.imageReference(aModelReference)
.buildImageMosaic();
}
This image mosaic is our domain object. It is added to the ILcdModel
in the decode
method.
The model decoder can now be used to create models. The full code of the model decoder is available at the end of this tutorial.
Strictly speaking, there is no need to implement the |
If you browse the LuciadLightspeed API, you will find a lot of classes related to Those classes remain in the product to guarantee backwards compatibility.
We do recommend using the |
Data generation code
The code we used for the generation of the fake data
private RenderedImage generateData() {
// Create an example AWT SampleModel that matches the semantics
SampleModel sampleModel = new PixelInterleavedSampleModel(DataBuffer.TYPE_SHORT, 1, 1, 1, 1, new int[]{0});
// First we determine our image's pixel size which is fixed to 256 x 256 in this case.
int pixelWidth = 256;
int pixelHeight = 256;
WritableRaster raster = WritableRaster.createWritableRaster(sampleModel.createCompatibleSampleModel(pixelWidth, pixelHeight), null);
// Then we generate the pixel values. Pixel values should be written to the image's raster
// in row-major order, starting at the upper-left pixel of the tile.
for (int r = 0; r < pixelHeight; r++) {
for (int c = 0; c < pixelWidth; c++) {
short value = (short) (r + c);
raster.setSample(c, r, 0, value);
}
}
// Then we create a RenderedImageImage with the correct pixel dimensions and a single band. In this case we will be
// reading/generating 256 x 256 16-bit integer values.
return new SingleTileRenderedImage(raster);
}
and the SingleTileRendererdImage
class:
/**
* A simple {@link java.awt.image.RenderedImage} that has one tile, completely in-memory.
*/
private static class SingleTileRenderedImage extends SimpleRenderedImage {
private final Raster fRaster;
private SingleTileRenderedImage(Raster aRaster) {
fRaster = aRaster;
minX = aRaster.getMinX();
minY = aRaster.getMinY();
width = aRaster.getWidth();
height = aRaster.getHeight();
tileWidth = aRaster.getWidth();
tileHeight = aRaster.getHeight();
sampleModel = aRaster.getSampleModel();
}
@Override
public Raster getTile(int tileX, int tileY) {
return fRaster;
}
}
Visualizing the data
The ILcdModel
created by the ILcdModelDecoder
is a regular model with raster data.
As such, it can be visualized and styled just like any other raster data model, DTED or GeoTIFF for instance.
See the Introduction to styling raster data tutorial for an introduction to visualizing and styling raster data on a Lightspeed map, or the Introduction to styling raster data tutorial for an introduction to visualizing and styling raster data on a GXY map.
Optional: registering the model decoder as a service
In your application, you typically want:
-
The same code path for opening custom raster data files and files of any of the standard raster formats like DTED and GeoTIFF. This makes your application code easier to write and maintain.
-
The same UI for the end user of your application to load data. The end user should not know which data formats are custom and which were supported by the LuciadLightspeed API out-of-the-box.
Therefore, it is recommended to register your model decoder as a service. You register a decoder as a service by adding an annotation to the class:
@LcdService(service = ILcdModelDecoder.class)
public class CustomRasterDecoder implements ILcdModelDecoder {
Now, when you need to decode data, you can use a composite model decoder instance that collects all registered model decoders, and can decode formats like DTED and GeoTIFF as well as your custom raster format.
ILcdModelDecoder decoder =
new TLcdCompositeModelDecoder(TLcdServiceLoader.getInstance(ILcdModelDecoder.class));
Full code
The full code of this tutorial is:
import java.awt.image.DataBuffer;
import java.awt.image.PixelInterleavedSampleModel;
import java.awt.image.Raster;
import java.awt.image.RenderedImage;
import java.awt.image.SampleModel;
import java.awt.image.WritableRaster;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import com.luciad.imaging.ALcdBandMeasurementSemantics;
import com.luciad.imaging.ALcdBandSemantics;
import com.luciad.imaging.ALcdBasicImage;
import com.luciad.imaging.ALcdImage;
import com.luciad.imaging.ILcdImageModelDescriptor;
import com.luciad.imaging.TLcdBandMeasurementSemanticsBuilder;
import com.luciad.imaging.TLcdImageBuilder;
import com.luciad.imaging.TLcdImageModelDescriptor;
import com.luciad.imaging.TLcdImageMosaicBuilder;
import com.luciad.model.ILcdModel;
import com.luciad.model.ILcdModelDecoder;
import com.luciad.model.ILcdModelReference;
import com.luciad.model.TLcdVectorModel;
import com.luciad.reference.TLcdGeodeticReference;
import com.luciad.shape.ILcdBounds;
import com.luciad.shape.shape2D.ILcd2DEditableBounds;
import com.luciad.shape.shape2D.TLcdLonLatBounds;
import com.luciad.util.ILcdFireEventMode;
import com.luciad.util.TLcdAltitudeUnit;
import com.luciad.util.iso19103.ILcdISO19103UnitOfMeasure;
import com.luciad.util.iso19103.TLcdISO19103MeasureTypeCodeExtension;
import com.luciad.util.iso19103.TLcdUnitOfMeasureFactory;
import com.luciad.util.service.LcdService;
import com.sun.media.imageioimpl.common.SimpleRenderedImage;
/**
* This class demonstrates how to implement a custom raster decoder from scratch. This decoder
* does not actually decode data from disk. Instead it generates fake elevation data in memory.
* This has been done to keep the decoder easy to understand.
*/
@LcdService(service = ILcdModelDecoder.class)
public class CustomRasterDecoder implements ILcdModelDecoder {
@Override
public boolean canDecodeSource(String aSourceName) {
//Normally you would do a check on aSourceName,
//for example checking the file extension.
//Since this decoder generates the model, we only accept a fake path
return Objects.equals(aSourceName, "Memory");
}
@Override
public String getDisplayName() {
return "Custom Raster Decoder";
}
@Override
public ILcdModel decode(String aSourceName) throws IOException {
//Sanity check to see whether we can decode the data
if (!canDecodeSource(aSourceName)) {
throw new IOException("Cannot decode " + aSourceName);
}
//Create the model reference and descriptor
ILcdModelReference modelReference = createModelReference();
ILcdImageModelDescriptor modelDescriptor = createModelDescriptor(aSourceName);
//Create the bounds and semantics of our dataset
ILcdBounds datasetBounds = createSpatialBounds();
List<ALcdBandSemantics> semantics = createSemantics();
//Create the domain object
ALcdImage mosaic = createDomainObject(modelReference, datasetBounds, semantics);
//Create the model and populate it with the ALcdImage domain object
TLcdVectorModel model = new TLcdVectorModel(modelReference, modelDescriptor);
model.addElement(mosaic, ILcdFireEventMode.NO_EVENT);
//Return the created model
return model;
}
private ILcdModelReference createModelReference() {
return new TLcdGeodeticReference();//This is a WGS-84 reference
}
private ILcdImageModelDescriptor createModelDescriptor(String aSourceName) {
return new TLcdImageModelDescriptor(
aSourceName,
"custom_mosaic", //Typename: identifier of the format
"Custom mosaic"//Display name for the model
);
}
private ILcdBounds createSpatialBounds() {
return new TLcdLonLatBounds(4, 51, 2, 3);
}
private ALcdImage createDomainObject(ILcdModelReference aModelReference,
ILcdBounds aDataSetBounds,
List<ALcdBandSemantics> aSemantics) {
// Here we create the mosaic containing the image tiles.
// This sample generates a 2 x 3 grid of images. Each tile corresponds to a 1 x 1 degree area.
// The tile grid that should be created depends on the underlying data format. Raster datasets
// in some formats, like for instance DTED, consist of many individual files. A decoder will
// then typically create a tile object per file.
// Simpler formats may only consist of a single file and will only require a single tile object
int tileColumns = 2;
int tileRows = 3;
double tileWidth = aDataSetBounds.getWidth() / tileColumns;
double tileHeight = aDataSetBounds.getHeight() / tileRows;
TLcdImageMosaicBuilder mosaicBuilder = TLcdImageMosaicBuilder.newBuilder();
for (int y = 0; y < tileRows; y++) {
for (int x = 0; x < tileColumns; x++) {
//Calculate the bounds of the tile
//Since it is a regular grid, it can be dereived from the total dataset bounds
ILcd2DEditableBounds tileBounds = new TLcdLonLatBounds(
aDataSetBounds.getLocation().getX() + tileWidth * x,
aDataSetBounds.getLocation().getY() + tileHeight * y,
tileWidth,
tileHeight
);
//Generate fake elevation data
//Normally this data would be read from e.g. a file
RenderedImage tileData = generateData();
//Convert the java.awt.image to an ALcdImage representing the tile
ALcdBasicImage tile = createTile(tileData, tileBounds, aModelReference, aSemantics);
//Add the tile to the mosaic, and indicate where it is positioned
mosaicBuilder.tile(tile, x, y);
}
}
//Configure the required parameters on the mosaic builder and finally build the mosaic.
return mosaicBuilder.bounds(aDataSetBounds)
.semantics(aSemantics)
.imageReference(aModelReference)
.buildImageMosaic();
}
private List<ALcdBandSemantics> createSemantics() {
// The 'unknown value' indicates the pixel value that represents that no data is available
// at that point. We use the same value as DTED here.
int unknownValue = -32767;
//The elevation values are expressed in metres.
//Here we create a unit of measure that expresses that
ILcdISO19103UnitOfMeasure uom =
TLcdUnitOfMeasureFactory.deriveUnitOfMeasure(TLcdAltitudeUnit.METRE,
TLcdISO19103MeasureTypeCodeExtension.TERRAIN_HEIGHT);
// Use the TLcdBandMeasurementSemanticsBuilder to express that the pixel values are measurements
ALcdBandMeasurementSemantics semantics = TLcdBandMeasurementSemanticsBuilder
.newBuilder()
.unitOfMeasure(uom)//here we indicate the unit for the measurements
.dataType(ALcdBandSemantics.DataType.SIGNED_SHORT)
.noDataValue(unknownValue)
.build();
//Our data has only a single band, so return a singleton collection
return Collections.singletonList(semantics);
}
private ALcdBasicImage createTile(RenderedImage aTileData,
ILcdBounds aTileBounds,
ILcdModelReference aModelReference,
List<ALcdBandSemantics> aTileSemantics) {
return TLcdImageBuilder.newBuilder()
.image(aTileData)
.semantics(aTileSemantics)
.bounds(aTileBounds)
.imageReference(aModelReference)
.buildBasicImage();
}
private RenderedImage generateData() {
// Create an example AWT SampleModel that matches the semantics
SampleModel sampleModel = new PixelInterleavedSampleModel(DataBuffer.TYPE_SHORT, 1, 1, 1, 1, new int[]{0});
// First we determine our image's pixel size which is fixed to 256 x 256 in this case.
int pixelWidth = 256;
int pixelHeight = 256;
WritableRaster raster = WritableRaster.createWritableRaster(sampleModel.createCompatibleSampleModel(pixelWidth, pixelHeight), null);
// Then we generate the pixel values. Pixel values should be written to the image's raster
// in row-major order, starting at the upper-left pixel of the tile.
for (int r = 0; r < pixelHeight; r++) {
for (int c = 0; c < pixelWidth; c++) {
short value = (short) (r + c);
raster.setSample(c, r, 0, value);
}
}
// Then we create a RenderedImageImage with the correct pixel dimensions and a single band. In this case we will be
// reading/generating 256 x 256 16-bit integer values.
return new SingleTileRenderedImage(raster);
}
/**
* A simple {@link java.awt.image.RenderedImage} that has one tile, completely in-memory.
*/
private static class SingleTileRenderedImage extends SimpleRenderedImage {
private final Raster fRaster;
private SingleTileRenderedImage(Raster aRaster) {
fRaster = aRaster;
minX = aRaster.getMinX();
minY = aRaster.getMinY();
width = aRaster.getWidth();
height = aRaster.getHeight();
tileWidth = aRaster.getWidth();
tileHeight = aRaster.getHeight();
sampleModel = aRaster.getSampleModel();
}
@Override
public Raster getTile(int tileX, int tileY) {
return fRaster;
}
}
}