Skip to content

Commit

Permalink
added geospatial crs transformations for opendrive
Browse files Browse the repository at this point in the history
  • Loading branch information
benediktschwab committed Aug 8, 2024
1 parent 7de834d commit 7c74a34
Show file tree
Hide file tree
Showing 15 changed files with 328 additions and 9 deletions.
1 change: 0 additions & 1 deletion buildSrc/src/main/kotlin/Configuration.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ object Project {
object ProjectComponents {
// user interface layer
const val main = ":rtron-main"
const val documentation = ":rtron-documentation"

// model transformation layer
const val model = ":rtron-model"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class SubcommandOpendriveToCitygml : CliktCommand(
help = "warning angle tolerance between two geometry elements in the plan view",
).double()
.default(OpendriveEvaluatorParameters.DEFAULT_PLAN_VIEW_GEOMETRY_ANGLE_WARNING_TOLERANCE)
private val reprojectModel by option(help = "reproject the geometries into a different geospatial coordinate reference system").flag()
private val crsEpsg by option(help = "EPSG code of the coordinate reference system used in the OpenDRIVE datasets").int()
.default(Opendrive2RoadspacesParameters.DEFAULT_CRS_EPSG)
private val addOffset by option(help = "offset values by which the model is translated along the x, y, and z axis").double().triple()
Expand Down Expand Up @@ -125,6 +126,7 @@ class SubcommandOpendriveToCitygml : CliktCommand(
planViewGeometryDistanceWarningTolerance = planViewGeometryDistanceWarningTolerance,
planViewGeometryAngleTolerance = planViewGeometryAngleTolerance,
planViewGeometryAngleWarningTolerance = planViewGeometryAngleWarningTolerance,
reprojectModel = reprojectModel,
crsEpsg = crsEpsg,
offsetX = addOffset.first,
offsetY = addOffset.second,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import io.rtron.transformer.evaluator.roadspaces.RoadspacesEvaluatorParameters
import io.rtron.transformer.modifiers.opendrive.cropper.OpendriveCropperParameters
import io.rtron.transformer.modifiers.opendrive.offset.adder.OpendriveOffsetAdderParameters
import io.rtron.transformer.modifiers.opendrive.remover.OpendriveObjectRemoverParameters
import io.rtron.transformer.modifiers.opendrive.reprojector.OpendriveReprojectorParameters
import kotlinx.serialization.Serializable

@Serializable
Expand All @@ -42,6 +43,7 @@ data class OpendriveToCitygmlParameters(
OpendriveEvaluatorParameters.DEFAULT_PLAN_VIEW_GEOMETRY_ANGLE_TOLERANCE,
val planViewGeometryAngleWarningTolerance: Double =
OpendriveEvaluatorParameters.DEFAULT_PLAN_VIEW_GEOMETRY_ANGLE_WARNING_TOLERANCE,
val reprojectModel: Boolean = OpendriveReprojectorParameters.DEFAULT_REPROJECT_MODEL,
val crsEpsg: Int = Opendrive2RoadspacesParameters.DEFAULT_CRS_EPSG,
val offsetX: Double = OpendriveOffsetAdderParameters.DEFAULT_OFFSET_X,
val offsetY: Double = OpendriveOffsetAdderParameters.DEFAULT_OFFSET_Y,
Expand Down Expand Up @@ -102,6 +104,13 @@ data class OpendriveToCitygmlParameters(
offsetHeading = OpendriveOffsetAdderParameters.DEFAULT_OFFSET_HEADING,
)

fun deriveOpendriveReprojectorParameters() =
OpendriveReprojectorParameters(
reprojectModel = reprojectModel,
targetCrsEpsg = crsEpsg,
deviationWarningTolerance = OpendriveReprojectorParameters.DEFAULT_DEVIATION_WARNING_TOLERANCE,
)

fun deriveOpendriveCropperParameters() =
OpendriveCropperParameters(
numberTolerance = tolerance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import io.rtron.transformer.modifiers.opendrive.cropper.OpendriveCropper
import io.rtron.transformer.modifiers.opendrive.offset.adder.OpendriveOffsetAdder
import io.rtron.transformer.modifiers.opendrive.offset.resolver.OpendriveOffsetResolver
import io.rtron.transformer.modifiers.opendrive.remover.OpendriveObjectRemover
import io.rtron.transformer.modifiers.opendrive.reprojector.OpendriveReprojector
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.createDirectories
Expand Down Expand Up @@ -95,9 +96,19 @@ class OpendriveToCitygmlProcessor(
return@processAllFiles
}

// reproject OpenDRIVE model
val opendriveReprojector = OpendriveReprojector(parameters.deriveOpendriveReprojectorParameters())
val opendriveReprojectorResult = opendriveReprojector.modify(modifiedOpendriveModel)
opendriveReprojectorResult.second.serializeToJsonFile(outputSubDirectoryPath / OPENDRIVE_REPROJECTOR_REPORT_PATH)
val opendriveReprojected =
opendriveReprojectorResult.first.handleEmpty {
logger.warn { "OpendriveReprojector: ${opendriveReprojectorResult.second.message}" }
return@processAllFiles
}

// offset OpenDRIVE model
val opendriveOffsetAdder = OpendriveOffsetAdder(parameters.deriveOpendriveOffsetAdderParameters())
val opendriveOffsetAddedResult = opendriveOffsetAdder.modify(modifiedOpendriveModel)
val opendriveOffsetAddedResult = opendriveOffsetAdder.modify(opendriveReprojected)
opendriveOffsetAddedResult.second.serializeToJsonFile(outputSubDirectoryPath / OPENDRIVE_OFFSET_ADDER_REPORT_PATH)

// resolve the offset
Expand Down Expand Up @@ -158,12 +169,13 @@ class OpendriveToCitygmlProcessor(
val REPORTS_PATH = Path("reports")
val OPENDRIVE_SCHEMA_VALIDATOR_REPORT_PATH = REPORTS_PATH / Path("01_opendrive_schema_validator_report.json")
val OPENDRIVE_EVALUATOR_REPORT_PATH = REPORTS_PATH / Path("02_opendrive_evaluator_report.json")
val OPENDRIVE_OFFSET_ADDER_REPORT_PATH = REPORTS_PATH / Path("03_opendrive_offset_adder_report.json")
val OPENDRIVE_OFFSET_RESOLVER_REPORT_PATH = REPORTS_PATH / Path("04_opendrive_offset_resolver_report.json")
val OPENDRIVE_CROP_REPORT_PATH = REPORTS_PATH / Path("05_opendrive_crop_report.json")
val OPENDRIVE_OBJECT_REMOVER_REPORT_PATH = REPORTS_PATH / Path("06_opendrive_object_remover_report.json")
val OPENDRIVE_TO_ROADSPACES_REPORT_PATH = REPORTS_PATH / Path("07_opendrive_to_roadspaces_report.json")
val ROADSPACES_EVALUATOR_REPORT_PATH = REPORTS_PATH / Path("08_roadspaces_evaluator_report.json")
val ROADSPACES_TO_CITYGML_REPORT_PATH = REPORTS_PATH / Path("09_roadspaces_to_citygml_report.json")
val OPENDRIVE_REPROJECTOR_REPORT_PATH = REPORTS_PATH / Path("03_opendrive_reprojector_report.json")
val OPENDRIVE_OFFSET_ADDER_REPORT_PATH = REPORTS_PATH / Path("04_opendrive_offset_adder_report.json")
val OPENDRIVE_OFFSET_RESOLVER_REPORT_PATH = REPORTS_PATH / Path("05_opendrive_offset_resolver_report.json")
val OPENDRIVE_CROP_REPORT_PATH = REPORTS_PATH / Path("06_opendrive_crop_report.json")
val OPENDRIVE_OBJECT_REMOVER_REPORT_PATH = REPORTS_PATH / Path("07_opendrive_object_remover_report.json")
val OPENDRIVE_TO_ROADSPACES_REPORT_PATH = REPORTS_PATH / Path("08_opendrive_to_roadspaces_report.json")
val ROADSPACES_EVALUATOR_REPORT_PATH = REPORTS_PATH / Path("09_roadspaces_evaluator_report.json")
val ROADSPACES_TO_CITYGML_REPORT_PATH = REPORTS_PATH / Path("10_roadspaces_to_citygml_report.json")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ class Rotation2D(
roll: Double = 0.0,
) = Rotation3D(angle, pitch, roll)

override fun toString(): String = "Rotation2D(angle=$angle)"

companion object {
val ZERO = Rotation2D(0.0)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package io.rtron.math.geometry.euclidean.twod.point

import io.rtron.math.geometry.euclidean.threed.point.Vector3D
import io.rtron.math.geometry.euclidean.twod.Rotation2D
import io.rtron.math.linear.RealVector
import io.rtron.math.std.fuzzyEquals as doubleFuzzyEquals
import org.apache.commons.math3.geometry.euclidean.twod.Vector2D as CMVector2D
import org.joml.Vector2d as JOMLVector2D
Expand Down Expand Up @@ -109,6 +110,8 @@ data class Vector2D(

fun toVector2DJOML() = JOMLVector2D(this.x, this.y)

fun toRealVector() = RealVector(doubleArrayOf(this.x, this.y))

companion object {
val ZERO = Vector2D(0.0, 0.0)
val X_AXIS = Vector2D(1.0, 0.0)
Expand Down
9 changes: 9 additions & 0 deletions rtron-math/src/main/kotlin/io/rtron/math/linear/RealMatrix.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package io.rtron.math.linear

import io.rtron.math.geometry.euclidean.threed.point.Vector3D
import io.rtron.math.geometry.euclidean.twod.point.Vector2D
import io.rtron.math.std.reshapeByColumnDimension
import org.apache.commons.math3.linear.Array2DRowRealMatrix
import org.apache.commons.math3.linear.MatrixUtils
Expand Down Expand Up @@ -195,6 +196,14 @@ class RealMatrix(
return RealMatrix(matrixValues.toTypedArray())
}

/**
* Creates a [RealMatrix] from a list of 3D vectors
*
* @param vectors list of 3D vectors whereby each vector will be represented as a row
*/
@JvmName("ofListVector2D")
fun of(vectors: List<Vector2D>): RealMatrix = RealMatrix(vectors.map { it.toRealVector() })

/**
* Creates a [RealMatrix] from a list of 3D vectors
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ fun List<Vector2D>.isClockwiseOrdered(): Boolean {
* Returns true, if the list of [Vector2D] has an anti-clockwise order.
*/
fun List<Vector2D>.isCounterClockwiseOrdered(): Boolean = !this.isClockwiseOrdered()

/**
* Calculates the centroid of a list of [Vector2D].
* See the wikipedia article of [Centroid](https://en.wikipedia.org/wiki/Centroid).
*/
fun List<Vector2D>.calculateCentroid(): Vector2D = this.reduce { sum, point -> sum + point }.div(this.size.toDouble())
17 changes: 17 additions & 0 deletions rtron-math/src/main/kotlin/io/rtron/math/transform/Affine2D.kt
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,23 @@ class Affine2D(
return Affine2D(matrix)
}

/**
* Creates an [Affine2D] transformation matrix from a [matrix] with column and row dimension of 2.
*/
fun of(matrix: RealMatrix): Affine2D {
require(matrix.columnDimension == 2) { "Wrong column dimension ${matrix.columnDimension}." }
require(matrix.rowDimension == 2) { "Wrong row dimension ${matrix.rowDimension}." }
require(matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0] > 0.0) {
"Determinant must be greater than zero, since it must not be reflection."
}
val jomlMatrix = JOMLMatrix3d()
jomlMatrix.m00 = matrix[0][0]
jomlMatrix.m01 = matrix[1][0]
jomlMatrix.m10 = matrix[0][1]
jomlMatrix.m11 = matrix[1][1]
return Affine2D(jomlMatrix)
}

/**
* Creates an [Affine2D] transformation matrix from a [rotation].
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ package io.rtron.model.opendrive.road.planview
import arrow.core.None
import arrow.core.Option
import arrow.optics.optics
import io.rtron.math.geometry.euclidean.twod.Pose2D
import io.rtron.math.geometry.euclidean.twod.Rotation2D
import io.rtron.math.geometry.euclidean.twod.point.Vector2D
import io.rtron.model.opendrive.core.OpendriveElement

@optics
Expand All @@ -35,4 +38,6 @@ data class RoadPlanViewGeometry(
var y: Double = Double.NaN,
) : OpendriveElement() {
companion object

fun getPose(): Pose2D = Pose2D(Vector2D(x, y), Rotation2D(hdg))
}
2 changes: 2 additions & 0 deletions rtron-transformer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,7 @@ dependencies {
implementation(Dependencies.slf4jSimple)

// geo libraries
implementation(Dependencies.proj4)
implementation(Dependencies.proj4Epsg)
implementation(Dependencies.citygml4jXml)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* Copyright 2019-2024 Chair of Geoinformatics, Technical University of Munich
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.rtron.transformer.modifiers.opendrive.reprojector

import arrow.core.None
import arrow.core.Option
import arrow.core.Some
import arrow.core.getOrElse
import arrow.core.toOption
import io.github.oshai.kotlinlogging.KotlinLogging
import io.rtron.math.geometry.euclidean.twod.point.Vector2D
import io.rtron.math.linear.RealMatrix
import io.rtron.math.linear.SingularValueDecomposition
import io.rtron.math.processing.calculateCentroid
import io.rtron.math.transform.Affine2D
import io.rtron.model.opendrive.OpendriveModel
import io.rtron.model.opendrive.additions.extensions.updateAdditionalIdentifiers
import io.rtron.model.opendrive.additions.optics.everyRoadPlanViewGeometry
import io.rtron.model.opendrive.core.HeaderGeoReference
import org.locationtech.proj4j.CRSFactory
import org.locationtech.proj4j.CoordinateReferenceSystem
import org.locationtech.proj4j.CoordinateTransform
import org.locationtech.proj4j.CoordinateTransformFactory
import org.locationtech.proj4j.ProjCoordinate

class OpendriveReprojector(
val parameters: OpendriveReprojectorParameters,
) {
// Properties and Initializers
private val logger = KotlinLogging.logger {}

// Methods

fun modify(opendriveModel: OpendriveModel): Pair<Option<OpendriveModel>, OpendriveReprojectorReport> {
val report = OpendriveReprojectorReport(parameters)
var modifiedOpendriveModel = opendriveModel.copy()
modifiedOpendriveModel.updateAdditionalIdentifiers()

if (!parameters.reprojectModel) {
report.message = "No reprojection configured."
return Some(modifiedOpendriveModel) to report
}
if (parameters.getTargetCrsEpsg().isNone()) {
report.message = "No target coordinate reference system EPSG code configured."
return None to report
}
if (modifiedOpendriveModel.header.geoReference.isNone()) {
report.message = "No source georeference defined in OpenDRIVE model."
return None to report
}

// setup coordinate transform
val crsFactory = CRSFactory()
val sourceCrs: CoordinateReferenceSystem =
try {
crsFactory.createFromParameters(
"source",
modifiedOpendriveModel.header.geoReference.getOrNull()!!.content,
)
} catch (e: Exception) {
report.message = "Source CRS could not be identified: " + e.message.toOption().getOrElse { "" }
return None to report
}
val targetCrs = crsFactory.createFromName("epsg:${parameters.targetCrsEpsg}")
val ctFactory = CoordinateTransformFactory()
val coordinateTransform: CoordinateTransform = ctFactory.createTransform(sourceCrs, targetCrs)

// reproject extracted geometry points
val planViewSourceCoordinates =
modifiedOpendriveModel.road
.flatMap { it.planView.geometry }
.map { ProjCoordinate(it.x, it.y) }
val planViewTargetCoordinates =
planViewSourceCoordinates.map {
val result = ProjCoordinate()
coordinateTransform.transform(it, result)
result
}

// estimate rigid affine transform
val (affine, maximumDeviation) = estimateRigidAffine(planViewSourceCoordinates, planViewTargetCoordinates)
report.maximumDeviation = maximumDeviation
if (maximumDeviation > parameters.deviationWarningTolerance) {
logger.warn {
"Maximum deviation is $maximumDeviation and thus exceeding the warning threshold " +
"of ${parameters.deviationWarningTolerance}."
}
}

// set new georeference and apply affine transform to the model
modifiedOpendriveModel.header.geoReference = Some(HeaderGeoReference(targetCrs.parameterString))
everyRoadPlanViewGeometry.modify(modifiedOpendriveModel) { currentPlanViewGeometry ->
val transformedPose = affine.transform(currentPlanViewGeometry.getPose())
currentPlanViewGeometry.x = transformedPose.point.x
currentPlanViewGeometry.y = transformedPose.point.y
currentPlanViewGeometry.hdg = transformedPose.rotation.angle
currentPlanViewGeometry
}

report.message = "Reprojection of translation with ${affine.extractTranslation()} and " +
"rotation with ${affine.extractRotation()} successfully applied. "
return Some(modifiedOpendriveModel) to report
}

/**
* Estimates a rigid affine matrix from two lists of corresponding coordinates.
* It uses a singular value decomposition for estimating the rotation.
*
* Further details can be found [here](https://nghiaho.com/?page_id=671).
*/
private fun estimateRigidAffine(
sourceCoordinates: List<ProjCoordinate>,
targetCoordinates: List<ProjCoordinate>,
): Pair<Affine2D, Double> {
val sourcePoints = sourceCoordinates.map { Vector2D(it.x, it.y) }
val targetPoints = targetCoordinates.map { Vector2D(it.x, it.y) }

// calculate centriods and local
val sourcePointsCentroid = sourcePoints.calculateCentroid()
val localSourcePoints = sourcePoints.map { it - sourcePointsCentroid }
val targetPointsCentroid = targetPoints.calculateCentroid()
val localTargetPoints = targetPoints.map { it - targetPointsCentroid }

// calculate covariance
val sourceMatrix = RealMatrix.of(localSourcePoints).transpose()
val targetMatrix = RealMatrix.of(localTargetPoints)
val covariance = sourceMatrix.multiply(targetMatrix)

// estimate affine transform matrix
val svd = SingularValueDecomposition(covariance)
val rotationAffine = Affine2D.of(svd.matrixVT.multiply(svd.matrixU.transpose()))
val translationAffine = Affine2D.of(targetPointsCentroid - rotationAffine.transform(sourcePointsCentroid))
val affine = translationAffine.append(rotationAffine)

// determine maximum deviation between rigid affine and geospatial projection
val maximumDeviation =
sourcePoints.zip(targetPoints).maxOfOrNull {
affine.transform(it.first).distance(it.second)
}!!

return affine to maximumDeviation
}
}
Loading

0 comments on commit 7c74a34

Please sign in to comment.