Skip to content

Commit

Permalink
Basic STAC API for GEE
Browse files Browse the repository at this point in the history
  • Loading branch information
m-mohr committed May 17, 2024
1 parent 6f9a1c2 commit e4f345e
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 19 deletions.
187 changes: 183 additions & 4 deletions src/api/collections.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Utils from '../utils/utils.js';
import Errors from '../utils/errors.js';
import GeeProcessing from '../processes/utils/processing.js';

export default class Data {

constructor(context) {
this.context = context;
this.catalog = context.collections();

this.geeSourceCatalogLink = {
Expand All @@ -22,12 +24,23 @@ export default class Data {

async beforeServerStart(server) {
server.addEndpoint('get', '/collections', this.getCollections.bind(this));
// Some endpoints may be routed through the /collections/{collection_id} endpoint due to the wildcard
server.addEndpoint('get', ['/collections/{collection_id}', '/collections/*'], this.getCollectionById.bind(this));
server.addEndpoint('get', '/collections/{collection_id}/queryables', this.getCollectionQueryables.bind(this));
// Some queryables may be routed through the /collections/{collection_id} endpoint due to the wildcard
server.addEndpoint('get', '/collections/{collection_id}/items', this.getCollectionItems.bind(this));
server.addEndpoint('get', '/collections/{collection_id}/items/{item_id}', this.getCollectionItemById.bind(this));
if (this.context.stacAssetDownload) {
server.addEndpoint('get', ['/assets/{asset_id}', '/assets/*'], this.getAssetById.bind(this));
}
server.addEndpoint('get', ['/thumbnails/{asset_id}', '/thumbnails/*'], this.getThumbnailById.bind(this));

const num = await this.catalog.loadCatalog();
console.log(`Loaded ${num} collections.`);

const pContext = this.context.processingContext({});
this.ee = await pContext.connectGee(true);
console.log(`Established connection to GEE for STAC`);

return num;
}

Expand Down Expand Up @@ -71,10 +84,16 @@ export default class Data {
// Redirect to correct route
return await this.getCollections(req, res);
}
// Some queryables may be routed through the /collections/{collection_id} endpoint due to the wildcard
// Some endpoints may be routed through the /collections/{collection_id} endpoint due to the wildcard
else if (id.endsWith('/queryables')) {
return await this.getCollectionQueryables(req, res);
}
else if (id.endsWith('/items')) {
return await this.getCollectionItems(req, res);
}
else if (id.match(/\/items\/[^/]+$/)) {
return await this.getCollectionItemById(req, res);
}

const collection = this.catalog.getData(id);
if (collection === null) {
Expand All @@ -85,10 +104,9 @@ export default class Data {
}

async getCollectionQueryables(req, res) {
// Get the ID from the normal parameter
let id = req.params.collection_id;
// Get the ID if this was a redirect from the /collections/{collection_id} endpoint
if (req.params['*'] && !req.params.collection_id) {
if (req.params['*'] && !id) {
id = req.params['*'].replace(/\/queryables$/, '');
}

Expand All @@ -100,4 +118,165 @@ export default class Data {
res.json(queryables);
}

async getCollectionItems(req, res) {
let id = req.params.collection_id;
// Get the ID if this was a redirect from the /collections/{collection_id} endpoint
if (req.params['*'] && !id) {
id = req.params['*'].replace(/\/items$/, '');
}

const collection = this.catalog.getData(id);
if (collection === null) {
throw new Errors.CollectionNotFound();
}

const limit = parseInt(req.query.limit, 10) || 10;
const offset = parseInt(req.query.offset, 10) || 0;

// Load the collection and read a "page" of items
const ic = this.ee.ImageCollection(id).toList(limit + 1, offset);
// Retrieve the items
let items;
try {
items = await GeeProcessing.evaluate(ic);
} catch (e) {
throw new Errors.Internal({message: e.message});
}

let hasNextPage = false;
// We requested one additional image to check if there is a next page
if (items.length > limit) {
hasNextPage = true;
items.pop();
}

// Convert to STAC
const features = items.map(item => this.catalog.convertImageToStac(item, id));
// Add links
const links = [
{
rel: "self",
href: Utils.getApiUrl(`/collections/${id}/items`),
type: "application/json"
}
]
if (offset > 0) {
links.push({
rel: "first",
href: Utils.getApiUrl(`/collections/${id}/items?limit=${limit}&offset=0`),
type: "application/json"
});
links.push({
rel: "prev",
href: Utils.getApiUrl(`/collections/${id}/items?limit=${limit}&offset=${Math.max(0, offset - limit)}`),
type: "application/json"
});
}
if (hasNextPage) {
links.push({
rel: "next",
href: Utils.getApiUrl(`/collections/${id}/items?limit=${limit}&offset=${offset + limit}`),
type: "application/json"
});
}

res.json({
type: "FeatureCollection",
features,
links,
timeStamp: Utils.toISODate(Date.now()),
numberReturned: features.length
});
}

async getCollectionItemById(req, res) {
let cid = req.params.collection_id;
let id = req.params.item_id;
// Get the ID if this was a redirect from the /collections/{collection_id} endpoint
if (req.params['*'] && (!cid || !id)) {
let match = req.params['*'].match(/(.+)\/items\/([^/]+)$/);
cid = match[1];
id = match[2];
}

const collection = this.catalog.getData(cid);
if (collection === null) {
throw new Errors.CollectionNotFound();
}

// Load the collection and read a "page" of items
const img = this.ee.Image(cid + '/' + id);
// Retrieve the item
let metadata;
try {
metadata = await GeeProcessing.evaluate(img);
} catch (e) {
throw new Errors.Internal({message: e.message});
}
// Convert to STAC and deliver
res.json(this.catalog.convertImageToStac(metadata, cid));
}

async getThumbnailById(req, res) {
const id = req.params['*'];

const idParts = id.split('/');
idParts.pop();
const cid = idParts.join('/');

const vis = this.catalog.getImageVisualization(cid);
if (vis === null) {
throw new Errors.Internal({message: 'No visualization parameters found.'});
}

const img = this.ee.Image(id);
const geeURL = await new Promise((resolve, reject) => {
img.visualize(vis.band_vis).getThumbURL({
dimensions: 1000,
crs: 'EPSG:3857',
format: 'jpg'
}, (url, err) => {
if (typeof err === 'string') {
reject(new Errors.Internal({message: err}));
}
else if (typeof url !== 'string' || url.length === 0) {
reject(new Errors.Internal({message: 'Download URL provided by Google Earth Engine is empty.'}));
}
else {
resolve(url);
}
});
});

return res.redirect(geeURL, Utils.noop);
}

async getAssetById(req, res) {
const id = req.params['*'];

const img = this.ee.Image(id);
const crs = 'EPSG:4326';
const geeURL = await new Promise((resolve, reject) => {
img.getDownloadURL({
dimensions: 1000,
region: img.geometry(null, crs),
crs,
filePerBand: false,
format: 'GEO_TIFF'
}, (url, err) => {
if (typeof err === 'string') {
reject(new Errors.Internal({message: err}));
}
else if (typeof url !== 'string' || url.length === 0) {
reject(new Errors.Internal({message: 'Download URL provided by Google Earth Engine is empty.'}));
}
else {
resolve(url);
}
});
});

return res.redirect(geeURL, Utils.noop);
}

}
125 changes: 120 additions & 5 deletions src/models/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default class DataCatalog {
if (obj.type !== 'Collection') {
return;
}
const collection = this.fixDataOnce(obj);
const collection = this.fixCollectionOnce(obj);
if (this.supportedGeeTypes.includes(collection['gee:type'])) {
this.collections[collection.id] = collection;
}
Expand Down Expand Up @@ -167,6 +167,17 @@ export default class DataCatalog {
}
}

getImageVisualization(id) {
const c = this.getData(id);
if (Array.isArray(c.summaries['gee:visualizations'])) {
let vis = c.summaries['gee:visualizations'];
if (vis.length > 0) {
return vis[0].image_visualization || null;
}
}
return null;
}

updateCollection(c, withSchema = false) {
c = Object.assign({}, c);
if (!withSchema) {
Expand Down Expand Up @@ -194,10 +205,117 @@ export default class DataCatalog {
title: "Queryables",
type: "application/schema+json"
});
if (c["gee:type"] === 'image_collection') {
c.links.push({
rel: 'items',
href: Utils.getApiUrl(`/collections/${c.id}/items`),
title: "Items",
type: "application/json"
});
}
return c;
}

fixDataOnce(c) {
convertImageToStac(img, collection) {
const omitProperties = [
"system:footprint",
"system:asset_size",
"system:index",
"system:time_start",
"system:time_end"
];

const id = img.properties["system:index"];
const geometry = img.properties["system:footprint"];
geometry.type = "Polygon";
geometry.coordinates = [geometry.coordinates];
const bbox = Utils.geoJsonBbox(geometry, true);

const bands = img.bands.map(b => ({
name: b.id,
// crs, , crs_transform, dimensions, data_type
}));

const properties = {};
for(const key in img.properties) {
if (!omitProperties.includes(key)) {
let newKey;
if (!key.includes(":")) {
newKey = `gee:${key.toLowerCase()}`;
}
else {
newKey = key.toLowerCase();
}
properties[newKey] = img.properties[key];
}
}
properties.datetime = Utils.toISODate(img.properties["system:time_start"]);
if (img.properties["system:time_end"]) {
properties.start_datetime = Utils.toISODate(img.properties["system:time_start"]);
properties.end_datetime = Utils.toISODate(img.properties["system:time_end"]);
}
properties.version = String(img.version);
properties["eo:bands"] = bands;

const links = [
{
rel: "self",
href: Utils.getApiUrl(`/collections/${collection}/items/${id}`),
type: "application/json"
},
{
rel: "root",
href: Utils.getApiUrl(`/`),
type: "application/json"
},
{
rel: "parent",
href: Utils.getApiUrl(`/collections/${collection}`),
type: "application/json"
},
{
rel: "collection",
href: Utils.getApiUrl(`/collections/${collection}`),
type: "application/json"
}
];

const assets = {
thumbnail: {
href: Utils.getApiUrl(`/thumbnails/${img.id}`),
type: "image/jpeg",
roles: ["thumbnail", "overview"]
}
};

if (this.serverContext.stacAssetDownload) {
assets.data = {
href: Utils.getApiUrl(`/assets/${img.id}`),
type: "image/tiff; application=geotiff",
roles: ["data"]
};
}

const stac = {
stac_version: "1.0.0",
stac_extensions: [
"https://stac-extensions.github.io/eo/v1.1.0/schema.json",
"https://stac-extensions.github.io/version/v1.0.0/schema.json",
],
type: "Feature",
id,
bbox,
geometry,
properties,
collection,
links,
assets
};

return stac;
}

fixCollectionOnce(c) {
// Fix invalid headers in markdown
if (typeof c.description === 'string') {
c.description = c.description.replace(/^(#){2,6}([^#\s].+)$/img, '$1 $2');
Expand All @@ -212,9 +330,6 @@ export default class DataCatalog {
c.summaries = {};
}

// Not a very useful information yet
delete c.summaries['gee:visualizations'];

// Fix invalid summaries
for(const key in c.summaries) {
const summary = c.summaries[key];
Expand Down
Loading

0 comments on commit e4f345e

Please sign in to comment.