From caf3fb3adf3a1501feb342c47ca3a18efd23e8c8 Mon Sep 17 00:00:00 2001 From: Seb-sti1 <65665540+seb-sti1@users.noreply.github.com> Date: Sat, 16 Dec 2023 15:40:29 +0100 Subject: [PATCH] Add Valhalla --- backend/package-lock.json | 15 ++ backend/package.json | 1 + backend/src/upload/file.processor.ts | 36 +++-- backend/src/upload/upload.service.ts | 130 +++++++++++++-- backend/src/upload/upload.ts | 13 +- backend/src/upload/valhalla.ts | 234 +++++++++++++++++++++++++++ 6 files changed, 400 insertions(+), 29 deletions(-) create mode 100644 backend/src/upload/valhalla.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 9cccf654..8abe865c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "nestjs-knex": "^2.0.0", "papaparse": "^5.4.1", "pg": "^8.11.3", + "polyline": "^0.2.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sharp": "^0.33.0", @@ -11953,6 +11954,15 @@ "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==", "dev": true }, + "node_modules/polyline": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/polyline/-/polyline-0.2.0.tgz", + "integrity": "sha512-rCJSkIHWZ/HOUoEWgjZ1DrRjLpTeTjgaktyJV0yhm8PugM5sKoavNjUHtI/amjsTn/Tq+Q3IIAuBD/dUSsWwxQ==", + "deprecated": "This module is now under the @mapbox namespace: install @mapbox/polyline instead", + "engines": { + "node": "*" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -23302,6 +23312,11 @@ "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==", "dev": true }, + "polyline": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/polyline/-/polyline-0.2.0.tgz", + "integrity": "sha512-rCJSkIHWZ/HOUoEWgjZ1DrRjLpTeTjgaktyJV0yhm8PugM5sKoavNjUHtI/amjsTn/Tq+Q3IIAuBD/dUSsWwxQ==" + }, "postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index ed6d9f6b..f8637107 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,6 +37,7 @@ "nestjs-knex": "^2.0.0", "papaparse": "^5.4.1", "pg": "^8.11.3", + "polyline": "^0.2.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sharp": "^0.33.0", diff --git a/backend/src/upload/file.processor.ts b/backend/src/upload/file.processor.ts index d76067d3..9ed6400c 100644 --- a/backend/src/upload/file.processor.ts +++ b/backend/src/upload/file.processor.ts @@ -83,21 +83,27 @@ export class FileProcessor { */ @Process('process-file') async handleFileProcessing(job: Job<{ filePath: string }>) { + const printJobInfo = (...args: any[]) => { + console.log(`[${job.id} ${job.toJSON()['progress']}%]`, ...args); + }; + + const printJobError = (...args: any[]) => { + console.error(`[${job.id} ${job.toJSON()['progress']}%]`, ...args); + }; + try { const { filePath } = job.data; const debug = process.env.IMPORT_DEBUG === 'true'; - console.log(`Processing file: ${filePath} (job id: ${job.id})`); + printJobInfo(`Processing file: ${filePath}`); //here we make sure that there is at least one RSP file and a HDC directory let surveys = find_surveys(filePath, debug); if (debug) { - console.debug(surveys); + printJobInfo(surveys); } if (surveys.length == 0) { - if (debug) { - console.log('No valid data found in directory: ' + filePath); - } + printJobInfo('No valid data found in directory: ' + filePath); } // TODO: split process here instead of for the all zip file (one process per survey) @@ -109,11 +115,19 @@ export class FileProcessor { ); const data = extract_measurements_data(surveys[i], debug); + if (!(await this.service.mapMatch(surveys[i], data))) { + printJobError('Failed to map match data.'); + } const roadImages = extract_road_image_data(surveys[i], debug); - const dashcameraImages = extract_dashcam_image_data(surveys[i], debug); + if (!(await this.service.mapMatch(surveys[i], roadImages))) { + printJobError('Failed to map match road images.'); + } - // TODO(Seb-sti1): (when rest working) add valhalla here + const dashcameraImages = extract_dashcam_image_data(surveys[i], debug); + if (!(await this.service.mapMatch(surveys[i], dashcameraImages))) { + printJobError('Failed to map match dashcam images.'); + } // Upload all data and images to the database await Promise.all([ @@ -145,18 +159,18 @@ export class FileProcessor { try { const tempFolderPath = path.dirname(job.data.filePath); fs.rmSync(tempFolderPath, { recursive: true }); - console.log(`Deleted extracted folder: ${job.data.filePath}`); + printJobInfo(`Deleted extracted folder: ${job.data.filePath}`); } catch (error) { - console.error( + printJobError( `Error deleting extracted folder: ${job.data.filePath}`, error, ); } - console.log( + printJobInfo( `File processed successfully and imported into Database: ${filePath}`, ); } catch (error) { - console.error(`Error processing file: ${job.data.filePath}`, error); + printJobError(`Error processing file: ${job.data.filePath}`, error); } } } diff --git a/backend/src/upload/upload.service.ts b/backend/src/upload/upload.service.ts index 6c4a30c8..a76ab24a 100644 --- a/backend/src/upload/upload.service.ts +++ b/backend/src/upload/upload.service.ts @@ -1,6 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectConnection, Knex } from 'nestjs-knex'; -import { SurveyImage, SurveyRoadParameters, SurveyStructure } from './upload'; +import { + SurveyData, + SurveyImage, + SurveyRoadParameters, + SurveyStructure, +} from './upload'; import { join } from 'path'; import { copyFileSync, existsSync, mkdirSync } from 'fs'; @@ -9,6 +14,12 @@ import * as sharp from 'sharp'; import { OSMWayId } from '../models'; import { IWay, Way } from '../tables'; import { getOSMWaysInARoad } from './osm'; +import { + distanceFromWayBeginningToShapePoint, + Edge, + getGpsPointAtDistance, + valhalla, +} from './valhalla'; @Injectable() export class UploadService { @@ -199,8 +210,10 @@ export class UploadService { /** * Get or create the ways corresponding to the OSMWayIds. - * You should definitely use this function instead of getOrCreateWay if you - * have multiple ways to get. + * + * You should definitely use this function instead of getOrCreateWay if you have multiple ways that + * are likely on the same road. + * * This executes the queries sequentially to avoid useless queries to OSM * and the database. This is especially useful when the ways are in the same road. * @@ -208,21 +221,16 @@ export class UploadService { * * @author Kerbourc'h */ - async getOrCreateWays(wayIds: OSMWayId[]) { + async getOrCreateWays(wayIds: OSMWayId[]): Promise { let tasks: any = wayIds.map((id, index) => index === 0 ? this.getOrCreateWay(id).then((value: IWay) => [value]) : id, ); return await tasks.reduce(async (cur: Promise, next: string) => { - return cur.then(async (value: IWay[]) => { - if (((value.length / tasks.length) * 100) % 10 === 0) - console.info( - 'Importation status', - `${(value.length / tasks.length) * 100}%`, - ); - - return [...value, await this.getOrCreateWay(next)]; - }); + return cur.then(async (value: IWay[]) => [ + ...value, + await this.getOrCreateWay(next), + ]); }); } @@ -234,7 +242,7 @@ export class UploadService { * * @author Kerbourc'h */ - async getOrCreateWay(OSMWayId: OSMWayId): Promise { + async getOrCreateWay(OSMWayId: OSMWayId): Promise | null { const way = await Way(this.knex_group_d) .select( 'id', @@ -281,4 +289,98 @@ export class UploadService { return way[0]; } + + /** + * Do the map matching of the data using valhalla. + * The result will directly be stored in the data array. + * + * @author Kerbourc'h + */ + async mapMatch(survey: SurveyStructure, data: SurveyData[]) { + let geometry = survey.geometry; + + // To have a good result for valhalla, first the point at the + // same distance from the beginning of the survey are removed + // the following arrays will allow to find back the corresponding data + let dataToPositionIndex: number[] = []; + let distances: number[] = []; + + for (let i = 0; i < data.length; i++) { + const exist = distances.indexOf(data[i].distance_survey); + + if (exist === -1) { + distances.push(data[i].distance_survey); + dataToPositionIndex.push(distances.length - 1); + } else { + dataToPositionIndex.push(exist); + } + } + + // find the gps coordinates of the distances along the survey + let positions = distances.map((d) => + getGpsPointAtDistance(geometry, d / 1000), + ); + + // use valhalla to map match that data (by construction matched_points.length === positions.length) + const matched = await valhalla(positions); + if (!matched) { + return false; + } + + // get or create the ways (also inserting the road if needed) using osm + const ways = await this.getOrCreateWays( + matched.edges.map((m) => String(m.way_id)), + ); + + // for each edge, pair it with the corresponding way + // and determine the direction of the edge along the way and + // the distance from the beginning of the way to the beginning of the edge + const edges: Edge[] = matched.edges.map((e, idx) => { + // calculate the distance from the beginning of the way to the beginning of the edge + const distance_way_start_edge_start = + distanceFromWayBeginningToShapePoint( + e.begin_shape, + ways[idx].section_geom.coordinates.map((c) => ({ + lat: c[1], + lng: c[0], + })), + ); + + // calculate the distance from the beginning of the way to the end of the edge + const distance_way_start_edge_end = distanceFromWayBeginningToShapePoint( + e.end_shape, + ways[idx].section_geom.coordinates.map((c) => ({ + lat: c[1], + lng: c[0], + })), + ); + + return { + beginning_from_way_start: distance_way_start_edge_start, + way: ways[idx], + // apply a minus if the edge is not in the same direction as the way + length: + distance_way_start_edge_start < distance_way_start_edge_end + ? e.length + : -e.length, + }; + }); + + for (let i = 0; i < data.length; i++) { + // find the corresponding matched data using dataToPositionIndex + const matched_data = matched.matched_points[dataToPositionIndex[i]]; + + // find corresponding edge + const edge = edges[matched_data.edge_index]; + + // edit data + data[i].fk_way_id = edge.way.id; + data[i].distance_way = + edge.beginning_from_way_start + + edge.length * matched_data.distance_ratio_along_edge; + data[i].position = matched_data.coordinates; + } + + return true; + } } diff --git a/backend/src/upload/upload.ts b/backend/src/upload/upload.ts index af60ce45..bee7409b 100644 --- a/backend/src/upload/upload.ts +++ b/backend/src/upload/upload.ts @@ -194,10 +194,15 @@ export function extractCoordinatesFromRSP(rspFilePath: string): LatLng[] { for (let i = 0; i < datas.length; i++) { const row = datas[i]; - coordinates.push({ - lng: Number(`${parseFloat(row[6])}`), - lat: Number(`${parseFloat(row[5])}`), - }); + // to fix bugged RSP file of Ballerup + const exist = coordinates.find( + (c) => c.lat == parseFloat(row[5]) && c.lng == parseFloat(row[6]), + ); + if (!exist) + coordinates.push({ + lng: Number(`${parseFloat(row[6])}`), + lat: Number(`${parseFloat(row[5])}`), + }); } return coordinates; diff --git a/backend/src/upload/valhalla.ts b/backend/src/upload/valhalla.ts new file mode 100644 index 00000000..af36f6f1 --- /dev/null +++ b/backend/src/upload/valhalla.ts @@ -0,0 +1,234 @@ +import { LatLng } from '../models'; +import * as polyline from 'polyline'; +import { IWay } from '../tables'; + +/** + * Calculate the distance between two points on the earth's surface. + * @param coord1 the first point + * @param coord2 the second point + * + * @author Liu + */ +function calculateDistance(coord1: LatLng, coord2: LatLng) { + const earthRadius = 6371; //Unit: kilometers + const [lat1, lon1] = [coord1.lat, coord1.lng]; + const [lat2, lon2] = [coord2.lat, coord2.lng]; + const dLat = (lat2 - lat1) * (Math.PI / 180); + const dLon = (lon2 - lon1) * (Math.PI / 180); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * (Math.PI / 180)) * + Math.cos(lat2 * (Math.PI / 180)) * + Math.sin(dLon / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); //Use the Haversine formula + return earthRadius * c; //// Returns the distance between two points in kilometers. +} + +/** + * Get the coordinates of the point at the given distance along the path + * @param coordinates path from the .rsp file + * @param givenDistance distance in km + * @param debug + * @returns { lat: number; lon: number } the coordinates of the point at the given distance + * + * @author Liu + */ +export function getGpsPointAtDistance( + coordinates: LatLng[], + givenDistance: number, + debug: boolean = false, +): LatLng { + let remainingDistance = givenDistance; + let targetSegmentIndex = -1; + + if (debug) + console.debug( + `before for, remainingDistance: ${remainingDistance}, targetSegmentIndex: ${targetSegmentIndex}`, + ); + + for (let i = 0; i < coordinates.length - 1; i++) { + const segmentDistance = calculateDistance( + coordinates[i], + coordinates[i + 1], + ); + if (remainingDistance > segmentDistance) { + remainingDistance -= segmentDistance; + } else { + targetSegmentIndex = i; + break; + } + } + + if (targetSegmentIndex === -1) { + if (debug) + console.warn( + 'The given distance is longer than the path length. The last point of the path is returned.', + ); + return coordinates[coordinates.length - 1]; + } + + if (debug) + console.debug( + `remainingDistance: ${remainingDistance}, targetSegmentIndex: ${targetSegmentIndex}`, + ); + + const [startLat, startLon] = [ + coordinates[targetSegmentIndex].lat, + coordinates[targetSegmentIndex].lng, + ]; + const [endLat, endLon] = [ + coordinates[targetSegmentIndex + 1].lat, + coordinates[targetSegmentIndex + 1].lng, + ]; + + if (debug) console.debug(startLat, startLon, endLat, endLon); + + const ratio = + remainingDistance / + calculateDistance( + coordinates[targetSegmentIndex], + coordinates[targetSegmentIndex + 1], + ); // Calculate the proportion of the target point's position in the current segment. + const targetLatitude = startLat + ratio * (endLat - startLat); + const targetLongitude = startLon + ratio * (endLon - startLon); + + return { lat: targetLatitude, lng: targetLongitude }; +} + +export interface RawEdge { + way_id: number; + begin_shape: LatLng; + end_shape: LatLng; + length: number; +} +export interface Edge { + beginning_from_way_start: number; + way: IWay; + length: number; +} +export interface MatchedPoint { + edge_index: number; + distance_ratio_along_edge: number; + coordinates: LatLng; +} + +/** + * The path of data that needs to be map matched. + * @param data the path of data. + * @result for each point of the data array, the way_id, the distance along the way and the length of the way is returned. + * + * @author Kerbourc'h + */ +export async function valhalla(data: LatLng[]): Promise<{ + edges: RawEdge[]; + matched_points: MatchedPoint[]; +}> { + const request = { + shape: data.map((v) => ({ lat: v.lat, lon: v.lng })), + costing: 'auto', + shape_match: 'map_snap', + directions_options: { + units: 'km', + }, + filters: { + attributes: [ + 'edge.way_id', + 'edge.length', + 'edge.begin_shape_index', + 'edge.end_shape_index', + 'matched.point', + 'matched.type', + 'matched.edge_index', + 'matched.distance_along_edge', + 'shape', + ], + action: 'include', + }, + }; + + return fetch(process.env.VALHALLA_ENDPOINT + '/trace_attributes', { + method: 'POST', + body: JSON.stringify(request), + headers: { 'Content-Type': 'application/json' }, + }) + .then(async (res) => { + const json = await res.json(); + if (json.error) return null; + + let lastValidEdgeIndex = 0; + + const shape: LatLng[] = polyline + .decode(json.shape, 6) + .map((c: number[]) => ({ lat: c[0], lng: c[1] })); + + const edges: RawEdge[] = json.edges.map( + (e: { + way_id: number; + begin_shape_index: number; + end_shape_index: number; + length: number; + }) => ({ + way_id: e.way_id, + begin_shape: shape[e.begin_shape_index], + end_shape: shape[e.end_shape_index], + length: e.length * 1000, + }), + ); + + const matched_data: MatchedPoint[] = json.matched_points.map( + (p: { + edge_index: number; + distance_along_edge: number; + lat: number; + lon: number; + }) => { + // homemade fix for https://github.com/valhalla/valhalla/issues/3699 + if (p.edge_index != 18446744073709551615) + lastValidEdgeIndex = p.edge_index; + + return { + edge_index: lastValidEdgeIndex, + distance_ratio_along_edge: p.distance_along_edge, + coordinates: { lat: p.lat, lng: p.lon }, + }; + }, + ); + + return { + edges: edges, + matched_points: matched_data, + }; + }) + .catch((err) => { + console.error("Error while fetching Valhalla's API: ", err); + return null; + }); +} + +/** + * Get the distance from the beginning of the way's geometry to the given point. + * This is only usefully for point of the way's geometry (hence coming from + * valhalla). + * + * @author Kerbourc'h + */ +export function distanceFromWayBeginningToShapePoint( + shape_point: LatLng, + geometry: LatLng[], +) { + // distance until geometry[i] + let distance = 0; + + for (let i = 0; i < geometry.length - 1; i++) { + if ( + calculateDistance(geometry[i], shape_point) < + calculateDistance(geometry[i + 1], shape_point) + ) { + return (distance + calculateDistance(geometry[i], shape_point)) * 1000; + } + const segmentDistance = calculateDistance(geometry[i], geometry[i + 1]); + distance += segmentDistance; + } + + return distance * 1000; +}