This article describes how to load and visualize GeoTIFF data in LuciadRIA. Because LuciadRIA doesn’t come with built-in GeoTIFF support, this article shows you how to implement a custom RasterTileSetModel that supports loading GeoTIFF data using the geotiff.js library. You can directly visualize the resulting model on a LuciadRIA map using a RasterTileSetLayer.

The approach in this article is suitable for small GeoTIFF files only.

GeoTIFF files can be large. They easily have a size of hundreds of megabytes or even gigabytes. If you’re working with such large GeoTIFF files, it’s recommended to use LuciadFusion as data management solution. Using LuciadFusion, you can serve GeoTIFF data to LuciadRIA using OGC WMS or WMTS web services, instead of sending the complete GeoTIFF data set to the client.

Project setup

We recommend starting from an existing LuciadRIA sample and stripping most of that sample’s code. For example, take the balloon sample and strip the contents of main.tsx, except the license loader import. This gives us a project that is ready to run. To get started, run npm run dev in the sample directory. If you run into any issues, check that you completed all the steps mentioned in the Editing, building and running the sample code article.

Loading GeoTIFF image data using geotiff.js

To load GeoTIFF data, we use the geotiff.js library (version 2.0.4). Its fromUrl method loads a GeoTIFF file from a URL.

geotiff.js is a small library to parse TIFF files for visualization or analysis, written in pure JavaScript. geotiff.js aims to support as many TIFF features as possible, including various image compression methods, geographical information, internal tiling, pixel or band interleaving, automatic transformation from several color spaces to RGB, and much more. The library is fully open source, under the MIT license.

To install the library, run:

npm install geotiff@2.0.4

GeoTIFF images can be tilesets with many levels of detail.The LuciadRIA support for tilesets currently focuses on power-of-two tilesets, which might not align with GeoTIFF’s generic tileset structure.That’s why we’re only interested in the highest level of detail.Using geotiff.js, we call getImage(0) to obtain the most detailed image.

Program: Load image data in the GeoTIFF format
import {fromUrl} from "geotiff";
export async function createGeoTiffRasterLayer(url: string) {
  const geoTiffFile = await fromUrl(url);
  const mostDetailedImage = await geoTiffFile.getImage(0);

Creating the model

We must create a custom RasterTileSetModel so that LuciadRIA knows how to handle our GeoTIFF image data.

First, we define the structure and properties of our tileset. Because we have only one image, we define a single tile level, row, and column. The tile width must equal the width and height of our image.

We also need to implement the model’s getImage method. This method must return our image.

Program: Create a custom RasterTileSetModel
import {GeoTIFFImage} from "geotiff";

class GeoTIFFRasterModel extends RasterTileSetModel {

  private _image: GeoTIFFImage;

  constructor(geoTiffImage: GeoTIFFImage) {
    const bbox = geoTiffImage.getBoundingBox(); // [minx, miny, maxx, maxy]
    const epsgCode = geoTiffImage.geoKeys.ProjectedCSTypeGeoKey || geoTiffImage.geoKeys.GeographicTypeGeoKey;
    if (!epsgCode) {
      throw new Error("Could not find georeference of GeoTIFF image");
    }
    const reference = getReference(`EPSG:${epsgCode}`);

    super({
      levelCount: 1,
      level0Rows: 1,
      level0Columns: 1,
      tileWidth: geoTiffImage.getWidth(),
      tileHeight: geoTiffImage.getHeight(),
      reference,
      bounds: createBounds(reference, [
        bbox[0],
        bbox[2] - bbox[0],
        bbox[1],
        bbox[3] - bbox[1]
      ])
    });

    this._image = geoTiffImage;
  }

  getImage(tile: TileCoordinate, onSuccess: (tile: TileCoordinate, image: HTMLImageElement) => void,
           onError: (tile: TileCoordinate, error?: any) => void, abortSignal: AbortSignal | null): void {
    this._image.readRGB().then((data) => {
      return toHTMLImage(data as Uint8Array, this.getTileWidth(tile.level)!, this.getTileHeight(tile.level)!);
    }).then((image: HTMLImageElement) => {
      onSuccess(tile, image);
    }).catch((error: any) => {
      onError(tile, error);
    })
  }
}
Program: Convert the data to an HTML image
async function toHTMLImage(geoTiffDataRGB: Uint8Array, width: number, height: number): Promise<HTMLImageElement> {
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d', {willReadFrequently: true}) as CanvasRenderingContext2D;
  const imageData = ctx.getImageData(0, 0, width, height);
  const data = imageData.data;  // array of RGBA values

  // convert GeoTiff's RGB values to ImageData's RGBA values
  for (let i = 0; i < height; i++) {
    for (let j = 0; j < width; j++) {
      const srcIdx = 3 * i * width + 3 * j;
      const idx = 4 * i * width + 4 * j;
      data[idx] = geoTiffDataRGB[srcIdx];
      data[idx + 1] = geoTiffDataRGB[srcIdx + 1];
      data[idx + 2] = geoTiffDataRGB[srcIdx + 2];
      data[idx + 3] = 255;  // fully opaque
    }
  }
  ctx.putImageData(imageData, 0, 0);

  return new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => {
      resolve(image);
    };
    image.onerror = reject;
    image.src = canvas.toDataURL();
  });
}

Creating the layer and adding it to the map

Now that we have our model, we can just create a RasterTileSetLayer with our model, and add it to our map.

Program: Create a RasterTileSetLayer
import {fromUrl} from "geotiff";
export async function createGeoTiffRasterLayer(url: string) {
  const geoTiffFile = await fromUrl(url);
  const mostDetailedImage = await geoTiffFile.getImage(0);
Program: Add the layer to the map
// this data can be found in a LightSpeed release (samples/resources/Data/GeoTIFF/Multispectral)
const SAMPLE_GEOTIFF_URL = "/data/LasVegas_May2003_LandSat7_Bands1234567_cropped.tif";
const geoTiffLayer = await createGeoTiffRasterLayer(SAMPLE_GEOTIFF_URL)
map.layerTree.addChild(geoTiffLayer);

Full sample code

main.tsx

import {createGeoTiffRasterLayer} from "./GeoTIFFLayerFactory.js";
import {loadReferencesFromWKT} from "@luciad/ria-sample-common-core/util/ReferenceLoader.js";
import {SingleMapSample} from "@luciad/ria-sample-common-ui/map/SingleMapSample.jsx";
import {getFitBounds} from "@luciad/ria-sample-common-core/util/FitBoundsUtil.js";
import * as React from "react";
import {createRoot} from "react-dom/client";
import {Map} from "@luciad/ria/view/Map.js";

// this data can be found in a LightSpeed release (samples/resources/Data/GeoTIFF/Multispectral)
const SAMPLE_GEOTIFF_URL = "/data/LasVegas_May2003_LandSat7_Bands1234567_cropped.tif";

async function init(map: Map) {
  // Support a large set of references by loading the EPSG database from sampledata
  const epsgStrings = await fetch("/sampledata/projection/epsg_coord_ref.txt").then(response => response.text());
  await loadReferencesFromWKT(epsgStrings);

  const geoTiffLayer = await createGeoTiffRasterLayer(SAMPLE_GEOTIFF_URL)
  map.layerTree.addChild(geoTiffLayer);

  await map.layerTree.whenReady();
  const fitBounds = await getFitBounds(geoTiffLayer);
  map.mapNavigator.fit({
    bounds: fitBounds!,
    animate: true
  });
}

const container =  document.getElementById("root")!;
const root = createRoot(container);
root.render(
    <SingleMapSample
        webgl={true} // raster reprojection is only supported in WebGL
        onInit={init}
    />
);

GeoTIFFLayerFactory.ts

import {RasterTileSetModel} from "@luciad/ria/model/tileset/RasterTileSetModel.js";
import {TileCoordinate} from "@luciad/ria/model/tileset/TileCoordinate.js";
import {fromUrl, GeoTIFFImage} from "geotiff";
import {RasterTileSetLayer} from "@luciad/ria/view/tileset/RasterTileSetLayer.js";
import {getReference} from "@luciad/ria/reference/ReferenceProvider.js";
import {createBounds} from "@luciad/ria/shape/ShapeFactory.js";

class GeoTIFFRasterModel extends RasterTileSetModel {

  private _image: GeoTIFFImage;

  constructor(geoTiffImage: GeoTIFFImage) {
    const bbox = geoTiffImage.getBoundingBox(); // [minx, miny, maxx, maxy]
    const epsgCode = geoTiffImage.geoKeys.ProjectedCSTypeGeoKey || geoTiffImage.geoKeys.GeographicTypeGeoKey;
    if (!epsgCode) {
      throw new Error("Could not find georeference of GeoTIFF image");
    }
    const reference = getReference(`EPSG:${epsgCode}`);

    super({
      levelCount: 1,
      level0Rows: 1,
      level0Columns: 1,
      tileWidth: geoTiffImage.getWidth(),
      tileHeight: geoTiffImage.getHeight(),
      reference,
      bounds: createBounds(reference, [
        bbox[0],
        bbox[2] - bbox[0],
        bbox[1],
        bbox[3] - bbox[1]
      ])
    });

    this._image = geoTiffImage;
  }

  getImage(tile: TileCoordinate, onSuccess: (tile: TileCoordinate, image: HTMLImageElement) => void,
           onError: (tile: TileCoordinate, error?: any) => void, abortSignal: AbortSignal | null): void {
    this._image.readRGB().then((data) => {
      return toHTMLImage(data as Uint8Array, this.getTileWidth(tile.level)!, this.getTileHeight(tile.level)!);
    }).then((image) => {
      onSuccess(tile, image);
    }).catch((error: any) => {
      onError(tile, error);
    })
  }
}

async function toHTMLImage(geoTiffDataRGB: Uint8Array, width: number, height: number): Promise<HTMLImageElement> {
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d', {willReadFrequently: true}) as CanvasRenderingContext2D;
  const imageData = ctx.getImageData(0, 0, width, height);
  const data = imageData.data;  // array of RGBA values

  // convert GeoTiff's RGB values to ImageData's RGBA values
  for (let i = 0; i < height; i++) {
    for (let j = 0; j < width; j++) {
      const srcIdx = 3 * i * width + 3 * j;
      const idx = 4 * i * width + 4 * j;
      data[idx] = geoTiffDataRGB[srcIdx];
      data[idx + 1] = geoTiffDataRGB[srcIdx + 1];
      data[idx + 2] = geoTiffDataRGB[srcIdx + 2];
      data[idx + 3] = 255;  // fully opaque
    }
  }
  ctx.putImageData(imageData, 0, 0);

  return new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => {
      resolve(image);
    };
    image.onerror = reject;
    image.src = canvas.toDataURL();
  });
}

export async function createGeoTiffRasterLayer(url: string) {
  const geoTiffFile = await fromUrl(url);
  const mostDetailedImage = await geoTiffFile.getImage(0);
  const model = new GeoTIFFRasterModel(mostDetailedImage);
  return new RasterTileSetLayer(model, {
    label: url.substring(url.lastIndexOf("/") + 1)
  });
}