Skip to content

Commit

Permalink
feat(lambda-tiler): create preview images for og:image BM-264 (#2921)
Browse files Browse the repository at this point in the history
#### Description

Adds a new endpoint `/v1/preview` which generates `og:image` previews
for a specific location.

```
/v1/preview/:tileSet/:tileMatrixSet/:z/:lon/:lat.:tileType
```

example:

```
/v1/preview/aerial/WebMercatorQuad/177.3998405/-39.0852555.webp
```


#### Intention

When people share links to basemaps they are currently presented with
the same image of Lyttelton, which while really nice looking is not
representative of what they are actually get a link to.

This is the first step in providing an actual image of the area people
are linked to, I will add a screenshot tester of this too.



#### Checklist
*If not applicable, provide explanation of why.*
- [ ] Tests updated
- [ ] Docs updated
- [x] Issue linked in Title
  • Loading branch information
blacha authored Aug 29, 2023
1 parent 7f70bd0 commit a074cc4
Show file tree
Hide file tree
Showing 9 changed files with 312 additions and 57 deletions.
7 changes: 6 additions & 1 deletion packages/cogify/src/cogify/cli/cli.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,18 @@ export const BasemapsCogifyConfigCommand = command({

const configPath = base58.encode(Buffer.from(outputPath));
const location = getImageryCenterZoom(im);
const locationHash = `#@${location.lat.toFixed(7)},${location.lon.toFixed(7)},z${location.zoom}`;
const lat = location.lat.toFixed(7);
const lon = location.lon.toFixed(7);
const locationHash = `#@${lat},${lon},z${location.zoom}`;
const url = `https://basemaps.linz.govt.nz/?config=${configPath}&i=${im.name}&tileMatrix=${im.tileMatrix}&debug${locationHash}`;
const preview = `https://basemaps.linz.govt.nz/v1/preview/${im.name}/${im.tileMatrix}/${location.zoom}/${lon}/${lat}.webp?config=${configPath}`;

logger.info(
{
imageryId: im.id,
path: outputPath,
url,
urlPreview: preview,
config: configPath,
title: im.title,
tileMatrix: im.tileMatrix,
Expand Down
44 changes: 44 additions & 0 deletions packages/lambda-tiler/src/cli/render.preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ConfigProviderMemory } from '@basemaps/config';
import { initConfigFromUrls } from '@basemaps/config/build/json/tiff.config.js';
import { ImageFormat, TileMatrixSet, TileMatrixSets } from '@basemaps/geo';
import { LogConfig, setDefaultConfig } from '@basemaps/shared';
import { fsa } from '@chunkd/fs';
import { LambdaHttpRequest, LambdaUrlRequest, UrlEvent } from '@linzjs/lambda';
import { Context } from 'aws-lambda';
import { pathToFileURL } from 'url';
import { renderPreview } from '../routes/preview.js';

const target = pathToFileURL(`/home/blacha/tmp/basemaps/bm-724/test-north-island_20230220_10m/`);
const location = { lat: -39.0852555, lon: 177.3998405 };
const z = 12;

const outputFormat = ImageFormat.Webp;
let tileMatrix: TileMatrixSet | null = null;

async function main(): Promise<void> {
const log = LogConfig.get();
const provider = new ConfigProviderMemory();
setDefaultConfig(provider);
const { tileSet, imagery } = await initConfigFromUrls(provider, [target]);

if (tileSet.layers.length === 0) throw new Error('No imagery found in path: ' + target);
log.info({ tileSet: tileSet.name, layers: tileSet.layers.length }, 'TileSet:Loaded');

for (const im of imagery) {
log.info({ url: im.uri, title: im.title, tileMatrix: im.tileMatrix, files: im.files.length }, 'Imagery:Loaded');
if (tileMatrix == null) {
tileMatrix = TileMatrixSets.find(im.tileMatrix);
log.info({ tileMatrix: im.tileMatrix }, 'Imagery:TileMatrix:Set');
}
}

if (tileMatrix == null) throw new Error('No tileMatrix found');

const req = new LambdaUrlRequest({ headers: {} } as UrlEvent, {} as Context, LogConfig.get()) as LambdaHttpRequest;
const res = await renderPreview(req, { tileMatrix, tileSet, location, z, outputFormat });
const previewFile = `./z${z}_${location.lon}_${location.lat}.${outputFormat}`;
await fsa.write(previewFile, Buffer.from(res.body, 'base64'));
log.info({ path: previewFile }, 'Tile:Write');
}

main();
4 changes: 4 additions & 0 deletions packages/lambda-tiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { versionGet } from './routes/version.js';
import { NotFound, OkResponse } from './util/response.js';
import { CoSources } from './util/source.cache.js';
import { St } from './util/source.tracer.js';
import { tilePreviewGet } from './routes/preview.js';

export const handler = lf.http(LogConfig.get());

Expand Down Expand Up @@ -93,6 +94,9 @@ handler.router.get('/v1/tiles/:tileSet/:tileMatrix/tile.json', tileJsonGet);
// Tiles
handler.router.get('/v1/tiles/:tileSet/:tileMatrix/:z/:x/:y.:tileType', tileXyzGet);

// Preview
handler.router.get('/v1/preview/:tileSet/:tileMatrix/:z/:lon/:lat', tilePreviewGet);

// Attribution
handler.router.get('/v1/tiles/:tileSet/:tileMatrix/attribution.json', tileAttributionGet);
handler.router.get('/v1/attribution/:tileSet/:tileMatrix/summary.json', tileAttributionGet);
Expand Down
157 changes: 157 additions & 0 deletions packages/lambda-tiler/src/routes/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { ConfigTileSetRaster } from '@basemaps/config';
import { Bounds, ImageFormat, LatLon, Projection, TileMatrixSet } from '@basemaps/geo';
import { CompositionTiff, Tiler } from '@basemaps/tiler';
import { SharpOverlay, TileMakerSharp } from '@basemaps/tiler-sharp';
import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
import { ConfigLoader } from '../util/config.loader.js';
import { Etag } from '../util/etag.js';
import { NotFound, NotModified } from '../util/response.js';
import { Validate } from '../util/validate.js';
import { DefaultBackground, DefaultResizeKernel, TileXyzRaster, isArchiveTiff } from './tile.xyz.raster.js';

export interface PreviewGet {
Params: {
tileSet: string;
tileMatrix: string;
lat: string;
lon: string;
z: string;
};
}

const PreviewSize = { width: 1200, height: 630 };
const OutputFormat = ImageFormat.Webp;

/**
* Serve a preview of a imagery set
*
* /v1/preview/:tileSet/:tileMatrixSet/:z/:lon/:lat
*
* @example
* Raster Tile `/v1/preview/aerial/WebMercatorQuad/12/177.3998405/-39.0852555`
*
*/
export async function tilePreviewGet(req: LambdaHttpRequest<PreviewGet>): Promise<LambdaHttpResponse> {
const tileMatrix = Validate.getTileMatrixSet(req.params.tileMatrix);
if (tileMatrix == null) throw new LambdaHttpResponse(404, 'Tile Matrix not found');

req.set('tileMatrix', tileMatrix.identifier);
req.set('projection', tileMatrix.projection.code);

// TODO we should detect the format based off the "Accept" header and maybe default back to webp
req.set('extension', OutputFormat);

const location = Validate.getLocation(req.params.lon, req.params.lat);
if (location == null) throw new LambdaHttpResponse(404, 'Preview location not found');
req.set('location', location);

const z = Math.round(parseFloat(req.params.z));
if (isNaN(z) || z < 0 || z > tileMatrix.maxZoom) throw new LambdaHttpResponse(404, 'Preview zoom invalid');

const config = await ConfigLoader.load(req);

req.timer.start('tileset:load');
const tileSet = await config.TileSet.get(config.TileSet.id(req.params.tileSet));
req.timer.end('tileset:load');
if (tileSet == null) return NotFound();
// Only raster previews are supported
if (tileSet.type !== 'raster') throw new LambdaHttpResponse(404, 'Preview invalid tile set type');

return renderPreview(req, { tileSet, tileMatrix, location, outputFormat: OutputFormat, z });
}

interface PreviewRenderContext {
/** Imagery to use */
tileSet: ConfigTileSetRaster;
/** output tilematrix to use */
tileMatrix: TileMatrixSet;
/** Center point of the preview */
location: LatLon;
/** Iamge format to render the preview as */
outputFormat: ImageFormat;
/** Zom level to be use, must be a integer */
z: number;
}
/**
* Render the preview!
*
* All the parameter validation is done in {@link tilePreviewGet} this function expects everything to align
*
* @returns 304 not modified if the ETag matches or 200 ok with the content of the image
*/
export async function renderPreview(req: LambdaHttpRequest, ctx: PreviewRenderContext): Promise<LambdaHttpResponse> {
const tileMatrix = ctx.tileMatrix;
// Convert the input lat/lon into the projected coordinates to make it easier to do math with
const coords = Projection.get(tileMatrix).fromWgs84([ctx.location.lon, ctx.location.lat]);

// use the input as the center point, but round it to the closest pixel to make it easier to do math
const point = tileMatrix.sourceToPixels(coords[0], coords[1], ctx.z);
const pointCenter = { x: Math.round(point.x), y: Math.round(point.y) };

// position of the preview in relation to the output screen
const screenBounds = new Bounds(
pointCenter.x - PreviewSize.width / 2,
pointCenter.y - PreviewSize.height / 2,
PreviewSize.width,
PreviewSize.height,
);

// Convert the screen bounds back into the source to find the assets we need to render the preview
const topLeft = tileMatrix.pixelsToSource(screenBounds.x, screenBounds.y, ctx.z);
const bottomRight = tileMatrix.pixelsToSource(screenBounds.right, screenBounds.bottom, ctx.z);
const sourceBounds = Bounds.fromBbox([topLeft.x, topLeft.y, bottomRight.x, bottomRight.y]);

const assetLocations = await TileXyzRaster.getAssetsForBounds(
req,
ctx.tileSet,
tileMatrix,
sourceBounds,
ctx.z,
true,
);

const cacheKey = Etag.key(assetLocations);
if (Etag.isNotModified(req, cacheKey)) return NotModified();

const assets = await TileXyzRaster.loadAssets(req, assetLocations);
const tiler = new Tiler(tileMatrix);

// Figure out what tiffs and tiles need to be read and where they are placed on the output image
const compositions: CompositionTiff[] = [];
for (const asset of assets) {
// there shouldn't be any Cotar archives in previews but ignore them to be safe
if (!isArchiveTiff(asset)) continue;
const result = tiler.getTiles(asset, screenBounds, ctx.z);
if (result == null) continue;
compositions.push(...result);
}

const tilerSharp = new TileMakerSharp(PreviewSize.width, PreviewSize.height);
// Load all the tiff tiles and resize/them into the correct locations
req.timer.start('compose:overlay');
const overlays = (await Promise.all(
compositions.map((comp) => tilerSharp.composeTileTiff(comp, DefaultResizeKernel)),
).then((items) => items.filter((f) => f != null))) as SharpOverlay[];
req.timer.end('compose:overlay');

// Create the output image and render all the individual pieces into them
const img = tilerSharp.createImage(DefaultBackground);
img.composite(overlays);

req.timer.start('compose:compress');
const buf = await tilerSharp.toImage(ctx.outputFormat, img);
req.timer.end('compose:compress');

req.set('layersUsed', overlays.length);
req.set('bytes', buf.byteLength);
const response = new LambdaHttpResponse(200, 'ok');
response.header(HttpHeader.ETag, cacheKey);
response.header(HttpHeader.CacheControl, 'public, max-age=604800, stale-while-revalidate=86400');
response.buffer(buf, 'image/' + ctx.outputFormat);

const shortLocation = [ctx.location.lon.toFixed(7), ctx.location.lat.toFixed(7)].join('_');
const suggestedFileName = `preview_${ctx.tileSet.name}_z${ctx.z}_${shortLocation}.${ctx.outputFormat}`;
response.header('Content-Disposition', `inline; filename=\"${suggestedFileName}\"`);

return response;
}
81 changes: 53 additions & 28 deletions packages/lambda-tiler/src/routes/tile.xyz.raster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,46 +24,62 @@ export function getTiffName(name: string): string {

export type CloudArchive = CogTiff | Cotar;

/** Check to see if a cloud archive is a Tiff or a Cotar */
export function isArchiveTiff(x: CloudArchive): x is CogTiff {
if (x instanceof CogTiff) return true;
if (x.source.uri.endsWith('.tiff')) return true;
if (x.source.uri.endsWith('.tif')) return true;
return false;
}

export const TileComposer = new TileMakerSharp(256);

const DefaultResizeKernel = { in: 'lanczos3', out: 'lanczos3' } as const;
const DefaultBackground = { r: 0, g: 0, b: 0, alpha: 0 };
export const DefaultResizeKernel = { in: 'lanczos3', out: 'lanczos3' } as const;
export const DefaultBackground = { r: 0, g: 0, b: 0, alpha: 0 };

export const TileXyzRaster = {
async getAssetsForTile(req: LambdaHttpRequest, tileSet: ConfigTileSetRaster, xyz: TileXyz): Promise<string[]> {
async getAssetsForBounds(
req: LambdaHttpRequest,
tileSet: ConfigTileSetRaster,
tileMatrix: TileMatrixSet,
bounds: Bounds,
zoom: number,
ignoreOverview = false,
): Promise<string[]> {
const config = await ConfigLoader.load(req);
const imagery = await getAllImagery(config, tileSet.layers, [xyz.tileMatrix.projection]);
const imagery = await getAllImagery(config, tileSet.layers, [tileMatrix.projection]);
const filteredLayers = filterLayers(req, tileSet.layers);

const output: string[] = [];
const tileBounds = xyz.tileMatrix.tileToSourceBounds(xyz.tile);

// All zoom level config is stored as Google zoom levels
const filterZoom = TileMatrixSet.convertZoomLevel(xyz.tile.z, xyz.tileMatrix, TileMatrixSets.get(Epsg.Google));
const filterZoom = TileMatrixSet.convertZoomLevel(zoom, tileMatrix, TileMatrixSets.get(Epsg.Google));
for (const layer of filteredLayers) {
if (layer.maxZoom != null && filterZoom > layer.maxZoom) continue;
if (layer.minZoom != null && filterZoom < layer.minZoom) continue;

const imgId = layer[xyz.tileMatrix.projection.code];
if (imgId == null) {
req.log.warn({ layer: layer.name, projection: xyz.tileMatrix.projection.code }, 'Failed to lookup imagery');
continue;
}
const imgId = layer[tileMatrix.projection.code];
// Imagery does not exist for this projection
if (imgId == null) continue;

const img = imagery.get(imgId);
if (img == null) {
req.log.warn(
{ layer: layer.name, projection: xyz.tileMatrix.projection.code, imgId },
'Failed to lookup imagery',
);
req.log.warn({ layer: layer.name, projection: tileMatrix.projection.code, imgId }, 'Failed to lookup imagery');
continue;
}
if (!tileBounds.intersects(Bounds.fromJson(img.bounds))) continue;
if (!bounds.intersects(Bounds.fromJson(img.bounds))) continue;

for (const c of img.files) {
if (!tileBounds.intersects(Bounds.fromJson(c))) continue;

if (img.overviews && img.overviews.maxZoom >= filterZoom && img.overviews.minZoom <= filterZoom) {
if (!bounds.intersects(Bounds.fromJson(c))) continue;

// If there are overviews and they exist for this zoom range and we are not ignoring them
// lets use the overviews instead!
if (
img.overviews &&
img.overviews.maxZoom >= filterZoom &&
img.overviews.minZoom <= filterZoom &&
ignoreOverview !== true
) {
output.push(fsa.join(img.uri, img.overviews.path));
break;
}
Expand All @@ -75,15 +91,9 @@ export const TileXyzRaster = {
return output;
},

async tile(req: LambdaHttpRequest, tileSet: ConfigTileSetRaster, xyz: TileXyz): Promise<LambdaHttpResponse> {
if (xyz.tileType === VectorFormat.MapboxVectorTiles) return NotFound();

const assetPaths = await this.getAssetsForTile(req, tileSet, xyz);
const cacheKey = Etag.key(assetPaths);
if (Etag.isNotModified(req, cacheKey)) return NotModified();

async loadAssets(req: LambdaHttpRequest, assets: string[]): Promise<CloudArchive[]> {
const toLoad: Promise<CloudArchive | null>[] = [];
for (const assetPath of assetPaths) {
for (const assetPath of assets) {
toLoad.push(
LoadingQueue((): Promise<CloudArchive | null> => {
if (assetPath.endsWith('.tar.co')) {
Expand All @@ -100,7 +110,22 @@ export const TileXyzRaster = {
);
}

const assets = (await Promise.all(toLoad)).filter((f) => f != null) as CloudArchive[];
return (await Promise.all(toLoad)).filter((f) => f != null) as CloudArchive[];
},

async getAssetsForTile(req: LambdaHttpRequest, tileSet: ConfigTileSetRaster, xyz: TileXyz): Promise<string[]> {
const tileBounds = xyz.tileMatrix.tileToSourceBounds(xyz.tile);
return TileXyzRaster.getAssetsForBounds(req, tileSet, xyz.tileMatrix, tileBounds, xyz.tile.z);
},

async tile(req: LambdaHttpRequest, tileSet: ConfigTileSetRaster, xyz: TileXyz): Promise<LambdaHttpResponse> {
if (xyz.tileType === VectorFormat.MapboxVectorTiles) return NotFound();

const assetPaths = await this.getAssetsForTile(req, tileSet, xyz);
const cacheKey = Etag.key(assetPaths);
if (Etag.isNotModified(req, cacheKey)) return NotModified();

const assets = await TileXyzRaster.loadAssets(req, assetPaths);

const tiler = new Tiler(xyz.tileMatrix);
const layers = await tiler.tile(assets, xyz.tile.x, xyz.tile.y, xyz.tile.z);
Expand Down
11 changes: 10 additions & 1 deletion packages/lambda-tiler/src/util/validate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ImageFormat, Projection, TileMatrixSet, TileMatrixSets, VectorFormat } from '@basemaps/geo';
import { ImageFormat, LatLon, Projection, TileMatrixSet, TileMatrixSets, VectorFormat } from '@basemaps/geo';
import { Const, isValidApiKey, truncateApiKey } from '@basemaps/shared';
import { getImageFormat } from '@basemaps/tiler';
import { LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
Expand Down Expand Up @@ -55,6 +55,15 @@ export const Validate = {
if (tileType === VectorFormat.MapboxVectorTiles) return VectorFormat.MapboxVectorTiles;
return null;
},

/** Validate that a lat and lon are between -90/90 and -180/180 */
getLocation(lonIn: string, latIn: string): LatLon | null {
const lat = parseFloat(latIn);
const lon = parseFloat(lonIn);
if (isNaN(lon) || lon < -180 || lon > 180) return null;
if (isNaN(lat) || lat < -90 || lat > 90) return null;
return { lon, lat };
},
/**
* Validate that the tile request is somewhat valid
* - Valid projection
Expand Down
Loading

0 comments on commit a074cc4

Please sign in to comment.