Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lambda-tiler): Add vector test tiles for the health endpoint. BM-1061 #3337

Merged
merged 7 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/lambda-tiler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
"@basemaps/tiler-sharp": "^7.7.0",
"@linzjs/geojson": "^7.5.0",
"@linzjs/lambda": "^4.0.0",
"@mapbox/vector-tile": "^2.0.3",
"p-limit": "^4.0.0",
"path-to-regexp": "^6.1.0",
"pbf": "^4.0.1",
"pixelmatch": "^5.1.0",
"sharp": "^0.33.0"
},
Expand Down
23 changes: 18 additions & 5 deletions packages/lambda-tiler/src/routes/__tests__/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FakeData } from '../../__tests__/config.data.js';
import { ConfigLoader } from '../../util/config.loader.js';
import { getTestBuffer, healthGet, TestTiles } from '../health.js';
import { TileXyzRaster } from '../tile.xyz.raster.js';
import { tileXyzVector } from '../tile.xyz.vector.js';

const ctx: LambdaHttpRequest = new LambdaAlbRequest(
{
Expand All @@ -28,11 +29,13 @@ describe('/v1/health', () => {
const sandbox = sinon.createSandbox();
const config = new ConfigProviderMemory();

const fakeTileSet = FakeData.tileSetRaster('health');
const fakeTileSetRaster = FakeData.tileSetRaster('health');
const fakeTileSetVector = FakeData.tileSetVector('topographic');
beforeEach(() => {
config.objects.clear();
sandbox.stub(ConfigLoader, 'getDefaultConfig').resolves(config);
config.put(fakeTileSet);
config.put(fakeTileSetRaster);
config.put(fakeTileSetVector);
});

afterEach(() => {
Expand All @@ -44,6 +47,7 @@ describe('/v1/health', () => {
// Given ... a bad get tile response
const BadResponse = new LambdaHttpResponse(500, 'Can not get Tile Set.');
sandbox.stub(TileXyzRaster, 'tile').resolves(BadResponse);
sandbox.stub(tileXyzVector, 'tile').resolves(BadResponse);

// When ...
const res = await healthGet(ctx);
Expand All @@ -55,20 +59,29 @@ describe('/v1/health', () => {

const Response1 = new LambdaHttpResponse(200, 'ok');
const Response2 = new LambdaHttpResponse(200, 'ok');
const Response3 = new LambdaHttpResponse(200, 'ok');
const Response4 = new LambdaHttpResponse(200, 'ok');

before(async () => {
const testTileFile1 = await getTestBuffer(TestTiles[0]);
Response1.buffer(testTileFile1);
const testTileFile2 = await getTestBuffer(TestTiles[1]);
Response2.buffer(testTileFile2);
const testTileFile3 = await getTestBuffer(TestTiles[2]);
Response3.buffer(testTileFile3);
const testTileFile4 = await getTestBuffer(TestTiles[3]);
Response4.buffer(testTileFile4);
});
// Prepare mock test tile response based on the static test tiles

it('Should give a 200 response', async () => {
// Given ... a series good get tile response
const callback = sandbox.stub(TileXyzRaster, 'tile');
callback.onCall(0).resolves(Response1);
callback.onCall(1).resolves(Response2);
const callbackRaster = sandbox.stub(TileXyzRaster, 'tile');
const callbackVector = sandbox.stub(tileXyzVector, 'tile');
callbackRaster.onCall(0).resolves(Response1);
callbackRaster.onCall(1).resolves(Response2);
callbackVector.onCall(0).resolves(Response3);
callbackVector.onCall(1).resolves(Response4);

// When ...
const res = await healthGet(ctx);
Expand Down
168 changes: 147 additions & 21 deletions packages/lambda-tiler/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
import * as fs from 'node:fs';

import { ConfigTileSetRaster } from '@basemaps/config';
import { ConfigTileSetRaster, ConfigTileSetVector, TileSetType } from '@basemaps/config';
import { GoogleTms, Nztm2000QuadTms } from '@basemaps/geo';
import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
import { VectorTile, VectorTileLayer } from '@mapbox/vector-tile';
import Protobuf from 'pbf';
import PixelMatch from 'pixelmatch';
import Sharp from 'sharp';
import { gunzipSync } from 'zlib';

import { ConfigLoader } from '../util/config.loader.js';
import { isGzip } from '../util/cotar.serve.js';
import { TileXyz } from '../util/validate.js';
import { TileXyzRaster } from './tile.xyz.raster.js';
import { tileXyzVector } from './tile.xyz.vector.js';

interface TestTile extends TileXyz {
buf?: Buffer;
location?: string;
}

export const TestTiles: TestTile[] = [
{ tileSet: 'health', tileMatrix: GoogleTms, tileType: 'png', tile: { x: 252, y: 156, z: 8 } },
{ tileSet: 'health', tileMatrix: Nztm2000QuadTms, tileType: 'png', tile: { x: 30, y: 33, z: 6 } },
{
tileSet: 'topographic',
tileMatrix: GoogleTms,
tileType: 'pbf',
tile: { x: 1009, y: 641, z: 10 },
location: 'Wellington',
},
{
tileSet: 'topographic',
tileMatrix: GoogleTms,
tileType: 'pbf',
tile: { x: 62, y: 40, z: 6 },
location: 'South Island',
},
];

const TileSize = 256;

export async function getTestBuffer(test: TestTile): Promise<Buffer> {
Expand All @@ -40,6 +61,121 @@ export async function updateExpectedTile(test: TestTile, newTileData: Buffer, di
await fs.promises.writeFile(`${expectedFileName}.diff.png`, imgPng);
}

async function validateRasterTile(
tileSet: ConfigTileSetRaster,
test: TestTile,
req: LambdaHttpRequest,
): Promise<LambdaHttpResponse | undefined> {
// Get the parse response tile to raw buffer
const response = await TileXyzRaster.tile(req, tileSet, test);
if (response.status !== 200) return new LambdaHttpResponse(500, response.statusDescription);
if (!Buffer.isBuffer(response._body)) throw new LambdaHttpResponse(500, 'Not a Buffer response content.');
const resImgBuffer = await Sharp(response._body).raw().toBuffer();

// Get test tile to compare
const testBuffer = await getTestBuffer(test);
test.buf = testBuffer;
const testImgBuffer = await Sharp(testBuffer).raw().toBuffer();

const outputBuffer = Buffer.alloc(testImgBuffer.length);
const missMatchedPixels = PixelMatch(testImgBuffer, resImgBuffer, outputBuffer, TileSize, TileSize);
if (missMatchedPixels) {
/** Uncomment this to overwite the expected files */
// await updateExpectedTile(test, response._body as Buffer, outputBuffer);
req.log.error({ missMatchedPixels, projection: test.tileMatrix.identifier, xyz: test.tile }, 'Health:MissMatch');
return new LambdaHttpResponse(500, 'TileSet does not match.');
}
return;
}

function propertyCheck(layer: VectorTileLayer, key: string, value: string): boolean {
Wentao-Kuang marked this conversation as resolved.
Show resolved Hide resolved
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
if (feature.properties[key] === value) return true;
}
return false;
}

function validateWellingtonTile(tile: VectorTile): LambdaHttpResponse | undefined {
// Validate Airport Label
if (!propertyCheck(tile.layers['aeroway'], 'name', 'Wellington Airport')) {
return new LambdaHttpResponse(500, 'Failed to find Wellington Airport from test tile.');
}
// Validate Place Label
if (!propertyCheck(tile.layers['place'], 'name', 'Wellington')) {
return new LambdaHttpResponse(500, 'Failed to find Wellington label from test tile.');
}

// Validate Coastline Exists
if (!propertyCheck(tile.layers['coastline'], 'class', 'coastline')) {
return new LambdaHttpResponse(500, 'Failed to find Coastline from test tile.');
}

// Validate Transportation
if (!propertyCheck(tile.layers['landcover'], 'class', 'grass')) {
return new LambdaHttpResponse(500, 'Failed to find grass landcover from test tile.');
}

// Validate Poi
if (!propertyCheck(tile.layers['poi'], 'name', 'Seatoun Wharf')) {
return new LambdaHttpResponse(500, 'Failed to find Seatoun Wharf Poi from test tile.');
}

// Validate Landcover
if (!propertyCheck(tile.layers['transportation'], 'name', 'Mt Victoria Tunnel')) {
return new LambdaHttpResponse(500, 'Failed to find grass Mt Victoria Tunnel from test tile.');
}
return;
}

function validateSouthIslandTile(tile: VectorTile): LambdaHttpResponse | undefined {
// Validate landuse
if (!propertyCheck(tile.layers['landuse'], 'name', 'Queenstown')) {
return new LambdaHttpResponse(500, 'Failed to find Wellington Airport from test tile.');
}
// Validate Place Label
if (!propertyCheck(tile.layers['place'], 'name', 'Christchurch')) {
return new LambdaHttpResponse(500, 'Failed to find Christchurch label from test tile.');
}

// Validate Water
if (!propertyCheck(tile.layers['water'], 'name', 'Tasman Lake')) {
return new LambdaHttpResponse(500, 'Failed to find Tasman Lake from test tile.');
}

// Validate Coastline Exists
if (!propertyCheck(tile.layers['coastline'], 'class', 'coastline')) {
return new LambdaHttpResponse(500, 'Failed to find Coastline from test tile.');
}

// Validate LandCover
if (!propertyCheck(tile.layers['landcover'], 'class', 'wood')) {
return new LambdaHttpResponse(500, 'Failed to find grass landcover from test tile.');
}

// Validate Transportation
if (!propertyCheck(tile.layers['transportation'], 'name', 'STATE HIGHWAY 6')) {
return new LambdaHttpResponse(500, 'Failed to find grass STATE HIGHWAY 6 from test tile.');
}
return;
}

async function validateVectorTile(
tileSet: ConfigTileSetVector,
test: TestTile,
req: LambdaHttpRequest,
): Promise<LambdaHttpResponse | undefined> {
// Get the parse response tile to raw buffer
const response = await tileXyzVector.tile(req, tileSet, test);
if (response.status !== 200) return new LambdaHttpResponse(500, response.statusDescription);
Wentao-Kuang marked this conversation as resolved.
Show resolved Hide resolved
if (!Buffer.isBuffer(response._body)) throw new LambdaHttpResponse(500, 'Not a Buffer response content.');
const buffer = isGzip(response._body) ? gunzipSync(response._body) : response._body;
const tile = new VectorTile(new Protobuf(buffer));
if (test.location === 'Wellington') return validateWellingtonTile(tile);
if (test.location === 'South Island') return validateSouthIslandTile(tile);
return;
}

/**
* Health request get health TileSets and validate with test TileSets
* - Valid response from get heath tile request
Expand All @@ -49,27 +185,17 @@ export async function updateExpectedTile(test: TestTile, newTileData: Buffer, di
*/
export async function healthGet(req: LambdaHttpRequest): Promise<LambdaHttpResponse> {
const config = await ConfigLoader.load(req);
const tileSet = await config.TileSet.get(config.TileSet.id('health'));
if (tileSet == null) throw new LambdaHttpResponse(500, 'TileSet: "health" not found');
for (const test of TestTiles) {
// Get the parse response tile to raw buffer
const response = await TileXyzRaster.tile(req, tileSet as ConfigTileSetRaster, test);
if (response.status !== 200) return new LambdaHttpResponse(500, response.statusDescription);
if (!Buffer.isBuffer(response._body)) throw new LambdaHttpResponse(500, 'Not a Buffer response content.');
const resImgBuffer = await Sharp(response._body).raw().toBuffer();

// Get test tile to compare
const testBuffer = await getTestBuffer(test);
test.buf = testBuffer;
const testImgBuffer = await Sharp(testBuffer).raw().toBuffer();

const outputBuffer = Buffer.alloc(testImgBuffer.length);
const missMatchedPixels = PixelMatch(testImgBuffer, resImgBuffer, outputBuffer, TileSize, TileSize);
if (missMatchedPixels) {
/** Uncomment this to overwite the expected files */
// await updateExpectedTile(test, response._body as Buffer, outputBuffer);
req.log.error({ missMatchedPixels, projection: test.tileMatrix.identifier, xyz: test.tile }, 'Health:MissMatch');
return new LambdaHttpResponse(500, 'TileSet does not match.');
const tileSet = await config.TileSet.get(config.TileSet.id(test.tileSet));
if (tileSet == null) throw new LambdaHttpResponse(500, `TileSet: ${test.tileSet} not found`);
if (tileSet.type === TileSetType.Raster) {
const response = await validateRasterTile(tileSet, test, req);
if (response) return response;
} else if (tileSet.type === TileSetType.Vector) {
const response = await validateVectorTile(tileSet, test, req);
if (response) return response;
} else {
throw new LambdaHttpResponse(500, `Invalid TileSet type for tileSet ${test.tileSet}`);
}
}

Expand Down
Wentao-Kuang marked this conversation as resolved.
Show resolved Hide resolved
Binary file not shown.
Binary file not shown.
Loading