Prerequisites

In this tutorial, it is assumed that you are familiar with the LuciadLightspeed concepts introduced in the Create your first Lightspeed application tutorial:

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:

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 a TLcdImageBuilder.

    • 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 of ALcdBandSemantics).

    • 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 ILcdModelDecoder interface if you want to create an ILcdModel of your custom format. You could also create the ILcdModel directly, without using or needing an ILcdModelDecoder.

If you browse the LuciadLightspeed API, you will find a lot of classes related to ILcdRaster in the com.luciad.format.raster package. The ILcdRaster API was the predecessor of the ALcdImage API.

Those classes remain in the product to guarantee backwards compatibility. We do recommend using the ALcdImage API whenever possible, including when you add support for your own format.

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;
    }
  }
}