Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
25 changes: 20 additions & 5 deletions orbit/export/georef_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

import numpy as np

from orbit.models.project import Project
from orbit.utils.coordinate_transform import (
AffineTransformer,
CoordinateTransformer,
DroneAssistedTransformer,
HomographyTransformer,
HybridTransformer,
)
Expand Down Expand Up @@ -78,9 +81,7 @@ def build_georef_data(
Dictionary with georeferencing data
"""
# Determine transform method string
if isinstance(transformer, HybridTransformer):
method = "homography"
elif isinstance(transformer, HomographyTransformer):
if isinstance(transformer, (HybridTransformer, HomographyTransformer, DroneAssistedTransformer)):
method = "homography"
elif isinstance(transformer, AffineTransformer):
method = "affine"
Expand Down Expand Up @@ -112,6 +113,20 @@ def build_georef_data(
except Exception:
orbit_version = "unknown"

# If the transformer has an active adjustment, bake it into the exported
# matrices so downstream consumers get the fully-adjusted transform.
# For pixel→ENU (transform_matrix): effective = transform_matrix @ A_inv
# For ENU→pixel (inverse_matrix): effective = A @ inverse_matrix
transform_matrix = transformer.transform_matrix
inverse_matrix = transformer.inverse_matrix
if transformer.adjustment is not None and not transformer.adjustment.is_identity():
A = transformer.adjustment.get_adjustment_matrix()
A_inv = np.linalg.inv(A)
if transform_matrix is not None:
transform_matrix = transform_matrix @ A_inv
if inverse_matrix is not None:
inverse_matrix = A @ inverse_matrix

# Build output structure
data = {
"format": "ORBIT Georeferencing Data",
Expand All @@ -131,8 +146,8 @@ def build_georef_data(
"longitude": transformer.reference_lon,
"latitude": transformer.reference_lat,
},
"transformation_matrix": _matrix_to_list(transformer.transform_matrix),
"inverse_matrix": _matrix_to_list(transformer.inverse_matrix),
"transformation_matrix": _matrix_to_list(transform_matrix),
"inverse_matrix": _matrix_to_list(inverse_matrix),
"scale_factors": {
"x_meters_per_pixel": scale_x,
"y_meters_per_pixel": scale_y,
Expand Down
35 changes: 24 additions & 11 deletions orbit/export/object_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,10 @@ def _project_onto_meter_centerline(
min_dist = dist
best_s = cumul_s + t_param * seg_len
seg_hdg = np.arctan2(dy, dx)
# t-offset: positive = left of road direction (OpenDRIVE convention)
# In meter space (y-up), cross = direction × offset gives correct sign
cross = dx * (py - proj_y) - dy * (px - proj_x)
best_t = dist if cross >= 0 else -dist
# Signed perpendicular distance from the segment direction (positive = left).
# Using the cross product formula directly (not dist to clamped endpoint)
# ensures correct t even when the anchor is outside the road's s-range.
best_t = (dx * (py - y1) - dy * (px - x1)) / seg_len
best_hdg = seg_hdg

cumul_s += seg_len
Expand Down Expand Up @@ -345,6 +345,8 @@ def _set_type_attributes(self, object_elem: etree.Element, obj: RoadObject) -> N
elif obj.type == ObjectType.GUARDRAIL:
object_elem.set('type', 'barrier')
object_elem.set('subtype', 'guardrail')
object_elem.set('orientation', obj.odr_orientation if obj.odr_orientation else 'none')
object_elem.set('hdg', f'{np.radians(obj.orientation):.6f}')
object_elem.set('height', f"{obj.dimensions.get('height', 0.81):.2f}")
object_elem.set('width', f"{obj.dimensions.get('width', 0.3):.2f}")
if obj.validity_length:
Expand Down Expand Up @@ -432,7 +434,7 @@ def _create_object_outline(
self._create_circular_outline(outline, obj, 12)

elif obj.type == ObjectType.GUARDRAIL:
self._create_polyline_outline(outline, obj)
self._create_polyline_outline(outline, obj, road_hdg)

elif obj.type in (ObjectType.TREE_BROADLEAF, ObjectType.BUSH):
self._create_circular_outline(outline, obj, 8)
Expand Down Expand Up @@ -474,19 +476,28 @@ def _create_circular_outline(
corner.set('z', '0.0')
corner.set('height', f'{height:.2f}')

def _create_polyline_outline(self, outline: etree.Element, obj: RoadObject) -> None:
"""Create polyline outline for guardrails."""
def _create_polyline_outline(self, outline: etree.Element, obj: RoadObject,
road_hdg: float = 0.0) -> None:
"""Create polyline outline for guardrails.

Rotates metric offsets into road-local (u=s-direction, v=t-direction)
coordinates by applying -road_hdg rotation.
"""
if not obj.points or len(obj.points) < 2:
return

height = obj.dimensions.get('height', 0.81)
cos_h = np.cos(-road_hdg)
sin_h = np.sin(-road_hdg)

if self.transformer:
pts_m = [self.transformer.pixel_to_meters(px, py) for px, py in obj.points]
ref_mx, ref_my = pts_m[0]
for pt_m in pts_m:
u = pt_m[0] - ref_mx
v = pt_m[1] - ref_my
dx = pt_m[0] - ref_mx
dy = pt_m[1] - ref_my
u = cos_h * dx - sin_h * dy
v = sin_h * dx + cos_h * dy
corner = etree.SubElement(outline, 'cornerLocal')
corner.set('u', f'{u:.4f}')
corner.set('v', f'{v:.4f}')
Expand All @@ -495,8 +506,10 @@ def _create_polyline_outline(self, outline: etree.Element, obj: RoadObject) -> N
else:
ref_x, ref_y = obj.points[0]
for px, py in obj.points:
u = (px - ref_x) * self.scale_x
v = (py - ref_y) * self.scale_x
dx = (px - ref_x) * self.scale_x
dy = (py - ref_y) * self.scale_x
u = cos_h * dx - sin_h * dy
v = sin_h * dx + cos_h * dy
corner = etree.SubElement(outline, 'cornerLocal')
corner.set('u', f'{u:.4f}')
corner.set('v', f'{v:.4f}')
Expand Down
36 changes: 30 additions & 6 deletions orbit/export/opendrive_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -979,14 +979,16 @@ def _compute_parampoly3_geometry(self, connecting_road: Road,
connecting_road, path_meters, is_start=False
)

# Override with connected road headings for C1 continuity
# Override with connected road headings for C1 continuity.
# Departure from a road's start (or arrival at a road's end) means
# traveling opposite to the road's forward direction → flip by π.
start_heading = self._override_with_road_heading(
connecting_road.predecessor_id, connecting_road.predecessor_contact,
start_heading
start_heading, flip_at_start=True
)
end_heading = self._override_with_road_heading(
connecting_road.successor_id, connecting_road.successor_contact,
end_heading
end_heading, flip_at_start=False
)

# Transform to local u/v frame (origin at start, u along heading)
Expand Down Expand Up @@ -1055,14 +1057,26 @@ def _resolve_cr_heading(self, connecting_road: Road, path_meters: list,
return 0.0

def _override_with_road_heading(self, road_id, contact_point,
current_heading: float) -> float:
"""Override heading with connected road's actual heading if available."""
current_heading: float,
flip_at_start: bool = True) -> float:
"""Override heading with connected road's actual heading if available.

flip_at_start controls directional semantics:
- True (predecessor/departure): flip when contact_point=="start" because
a CR departing from a road's start travels opposite the road's +s direction.
- False (successor/arrival): flip when contact_point=="end" because a CR
arriving at a road's end also travels opposite the road's +s direction.
"""
road = self.road_map.get(road_id)
if road and road.centerline_id:
hdg = self._get_road_heading_at_contact_meters(
road.centerline_id, contact_point
)
if hdg is not None:
needs_flip = (flip_at_start and contact_point == "start") or \
(not flip_at_start and contact_point == "end")
if needs_flip:
hdg += math.pi
return hdg
return current_heading

Expand Down Expand Up @@ -1140,7 +1154,17 @@ def _build_cr_road_xml(self, connecting_road: Road,
elif self.carla_compat:
road_elem.append(etree.Element('signals'))

if self.carla_compat:
# Objects on connecting road (e.g. lampposts placed on connecting roads)
export_objects = getattr(self, '_export_objects', None)
if export_objects is None:
export_objects = self._get_export_objects()
cr_objects = self.object_builder.create_objects(
connecting_road, export_objects, connecting_road.inline_path,
geometry_elements=geometry_elements, road_length=road_length,
)
if cr_objects is not None:
road_elem.append(cr_objects)
elif self.carla_compat:
road_elem.append(etree.Element('objects'))

return road_elem
Expand Down
2 changes: 1 addition & 1 deletion orbit/export/signal_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def _project_point_onto_polyline(px: float, py: float, pts: List[tuple]):
if dist < min_dist:
min_dist = dist
best_s = cumulative_s + t * seg_len
cross = (px - x1) * dy - (py - y1) * dx
cross = dx * (py - y1) - dy * (px - x1) # standard signed cross product
best_t = (1.0 if cross >= 0 else -1.0) * dist
cumulative_s += seg_len

Expand Down
67 changes: 67 additions & 0 deletions orbit/gui/dialogs/connecting_road_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,28 @@ def setup_ui(self):
convert_layout.addWidget(self.convert_to_polyline_btn)
curve_layout.addRow("Convert:", convert_layout)

# Smooth Curve section — available for any geometry type
smooth_layout = self.add_form_group_with_info(
"Smooth Curve",
"Redistribute intermediate points along a smooth Bezier curve while "
"preserving the start/end positions and tangent angles."
)
self.smooth_curve_btn = QPushButton("Smooth Curve")
self.smooth_curve_btn.setToolTip(
"Redistribute the curve's control points so the path is smooth and "
"G1-continuous with adjacent roads."
)
self.smooth_curve_btn.clicked.connect(self.on_smooth_curve)
self.smooth_points_spin = QSpinBox()
self.smooth_points_spin.setRange(10, 500)
self.smooth_points_spin.setValue(50)
self.smooth_points_spin.setSuffix(" pts")
self.smooth_points_spin.setToolTip("Number of output points for the smoothed curve")
smooth_row = QHBoxLayout()
smooth_row.addWidget(self.smooth_points_spin)
smooth_row.addWidget(self.smooth_curve_btn)
smooth_layout.addRow("", smooth_row)

# Create standard OK/Cancel buttons
self.create_button_box()

Expand Down Expand Up @@ -459,6 +481,51 @@ def on_regenerate_curve(self):
if self.connecting_road.id in image_view.connecting_road_lanes_items:
image_view.connecting_road_lanes_items[self.connecting_road.id].update_graphics()

def on_smooth_curve(self):
"""Redistribute inline_path points along a smooth Bezier curve."""
from orbit.gui.undo_commands import SmoothCRCommand
from orbit.utils.geometry import fit_smooth_curve_to_polyline, get_smooth_cr_tangents

path = self.connecting_road.inline_path
if not path or len(path) < 2:
QMessageBox.warning(self, "Smooth Curve", "No curve points to smooth.")
return

cr = self.connecting_road
# Prefer stored headings (authoritative, set when CR was generated)
if cr.stored_start_heading is not None and cr.stored_end_heading is not None:
start_hdg, end_hdg = cr.stored_start_heading, cr.stored_end_heading
else:
tangents = get_smooth_cr_tangents(cr, self.project)
if tangents is None:
QMessageBox.warning(
self, "Smooth Curve",
"Could not determine tangent directions from adjacent roads."
)
return
start_hdg, end_hdg = tangents

old_path = list(path)
n_out = max(self.smooth_points_spin.value(), 2)
new_path = fit_smooth_curve_to_polyline(path, start_hdg, end_hdg, num_output_points=n_out)
cr.inline_path = new_path

# Push undo command if main window has a stack
main_win = self.parent()
if hasattr(main_win, 'undo_stack') and hasattr(main_win, 'image_view'):
cmd = SmoothCRCommand(
main_win.image_view,
cr,
old_path,
new_path,
)
main_win.undo_stack.push(cmd)

# Update graphics
if hasattr(main_win, 'image_view'):
image_view = main_win.image_view
image_view.update_connecting_road_graphics(cr.id)

def accept(self):
"""Save changes and accept dialog."""
# Save predecessor/successor roads
Expand Down
51 changes: 30 additions & 21 deletions orbit/gui/dialogs/export_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,16 @@ class ExportDialog(BaseDialog):
"""Dialog for OpenDrive export with preview."""

def __init__(self, project: Project, parent=None, xodr_schema_path: Optional[str] = None,
adjustment=None):
transformer_factory=None, adjustment=None):
super().__init__("Export to OpenDrive", parent, min_width=700, min_height=600)

self.project = project
self.transformer: Optional[CoordinateTransformer] = None
self.output_path: Optional[Path] = None
self.xodr_schema_path = xodr_schema_path # Path to XSD schema for validation (optional)
self._adjustment = adjustment # TransformAdjustment from interactive fine-tuning
self.xodr_schema_path = xodr_schema_path
self._transformer_factory = transformer_factory
# Legacy: adjustment kept for callers that don't provide a factory
self._adjustment = adjustment

self.setup_ui()
self.load_properties()
Expand Down Expand Up @@ -252,14 +254,16 @@ def analyze_project(self):
# Check georeferencing
if self.project.has_georeferencing():
# Create transformer using project's method
self.transformer = create_transformer(
self.project.control_points,
self.project.transform_method,
use_validation=True,
)

if self.transformer and self._adjustment and not self._adjustment.is_identity():
self.transformer.set_adjustment(self._adjustment)
if self._transformer_factory:
self.transformer = self._transformer_factory(use_validation=True)
else:
self.transformer = create_transformer(
self.project.control_points,
self.project.transform_method,
use_validation=True,
)
if self.transformer and self._adjustment and not self._adjustment.is_identity():
self.transformer.set_adjustment(self._adjustment)

if self.transformer:
method = self.project.transform_method.upper()
Expand Down Expand Up @@ -453,22 +457,27 @@ def do_export(self):
# Create a fresh transformer that uses pyproj for the export
# projection. This ensures the homography/affine matrix is
# computed in the same coordinate system written to the file.
export_transformer = create_transformer(
self.project.control_points,
self.project.transform_method,
use_validation=True,
export_proj_string=proj_string
)
if self._transformer_factory:
export_transformer = self._transformer_factory(
use_validation=True,
export_proj_string=proj_string,
)
else:
export_transformer = create_transformer(
self.project.control_points,
self.project.transform_method,
use_validation=True,
export_proj_string=proj_string
)
# Apply manual alignment adjustment if one is active
if self._adjustment and not self._adjustment.is_identity():
export_transformer.set_adjustment(self._adjustment)
if not export_transformer:
show_error(self, "Failed to create export transformer.", "Export Error")
self.export_btn.setEnabled(True)
self.status_label.setText("<b>Export failed.</b>")
return

# Apply manual alignment adjustment if one is active
if self._adjustment and not self._adjustment.is_identity():
export_transformer.set_adjustment(self._adjustment)

# Compute origin offset: project the selected origin lat/lon
# to get the offset that will be subtracted from all coordinates.
origin_selection = self.origin_combo.currentData()
Expand Down
Loading
Loading