Skip to content

Commit 446a4b0

Browse files
author
Yann N.
committed
Ensure consistent use of pyproj.Transform for Lat/Lon<->Proj CS conversions
1 parent cd2452c commit 446a4b0

File tree

3 files changed

+137
-82
lines changed

3 files changed

+137
-82
lines changed

opensfm/actions/export_geocoords.py

Lines changed: 4 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
import pyproj
88
from numpy.typing import NDArray
99
from opensfm import io, types
10+
from opensfm import geo
1011
from opensfm.dataset import DataSet, UndistortedDataSet
11-
from opensfm.geo import TopocentricConverter
1212

1313
logger: logging.Logger = logging.getLogger(__name__)
1414

@@ -40,8 +40,8 @@ def run_dataset(
4040

4141
reference = data.load_reference()
4242

43-
projection = pyproj.Proj(proj)
44-
t = _get_transformation(reference, projection)
43+
projection = geo.construct_proj_transformer(proj, inverse=True)
44+
t = geo.get_proj_transform_matrix(reference, projection)
4545

4646
if transformation:
4747
output = output or "geocoords_transformation.txt"
@@ -57,7 +57,7 @@ def run_dataset(
5757
if reconstruction:
5858
reconstructions = data.load_reconstruction()
5959
for r in reconstructions:
60-
_transform_reconstruction(r, t)
60+
geo.transform_reconstruction_with_proj(r, projection)
6161
output = output or "reconstruction.geocoords.json"
6262
data.save_reconstruction(reconstructions, output)
6363

@@ -68,24 +68,6 @@ def run_dataset(
6868
_transform_dense_point_cloud(udata, t, output_path)
6969

7070

71-
def _get_transformation(
72-
reference: TopocentricConverter, projection: pyproj.Proj
73-
) -> NDArray:
74-
"""Get the linear transform from reconstruction coords to geocoords."""
75-
p = [[1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 0, 0]]
76-
q = [_transform(point, reference, projection) for point in p]
77-
78-
transformation = np.array(
79-
[
80-
[q[0][0] - q[3][0], q[1][0] - q[3][0], q[2][0] - q[3][0], q[3][0]],
81-
[q[0][1] - q[3][1], q[1][1] - q[3][1], q[2][1] - q[3][1], q[3][1]],
82-
[q[0][2] - q[3][2], q[1][2] - q[3][2], q[2][2] - q[3][2], q[3][2]],
83-
[0, 0, 0, 1],
84-
]
85-
)
86-
return transformation
87-
88-
8971
def _write_transformation(transformation: NDArray, filename: str) -> None:
9072
"""Write the 4x4 matrix transformation to a text file."""
9173
with io.open_wt(filename) as fout:
@@ -94,15 +76,6 @@ def _write_transformation(transformation: NDArray, filename: str) -> None:
9476
fout.write("\n")
9577

9678

97-
def _transform(
98-
point: Sequence[float], reference: TopocentricConverter, projection: pyproj.Proj
99-
) -> List[float]:
100-
"""Transform on point from local coords to a proj4 projection."""
101-
lat, lon, altitude = reference.to_lla(point[0], point[1], point[2])
102-
easting, northing = projection(lon, lat)
103-
return [easting, northing, altitude]
104-
105-
10679
def _transform_image_positions(
10780
reconstructions: List[types.Reconstruction], transformation: NDArray, output: str
10881
) -> None:
@@ -121,22 +94,6 @@ def _transform_image_positions(
12194
fout.write(text)
12295

12396

124-
def _transform_reconstruction(
125-
reconstruction: types.Reconstruction, transformation: NDArray
126-
) -> None:
127-
"""Apply a transformation to a reconstruction in-place."""
128-
A, b = transformation[:3, :3], transformation[:3, 3]
129-
A1 = np.linalg.inv(A)
130-
131-
for shot in reconstruction.shots.values():
132-
R = shot.pose.get_rotation_matrix()
133-
shot.pose.set_rotation_matrix(np.dot(R, A1))
134-
shot.pose.set_origin(np.dot(A, shot.pose.get_origin()) + b)
135-
136-
for point in reconstruction.points.values():
137-
point.coordinates = list(np.dot(A, point.coordinates) + b)
138-
139-
14097
def _transform_dense_point_cloud(
14198
udata: UndistortedDataSet, transformation: NDArray, output_path: str
14299
) -> None:

opensfm/geo.py

Lines changed: 107 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# pyre-strict
2-
from typing import overload, Sequence, Tuple, Union
2+
from typing import List, overload, Sequence, Tuple, Union
33

44
import numpy as np
5+
import pyproj
56
from numpy.typing import NDArray
67

78
Scalars = Union[float, NDArray]
@@ -250,26 +251,6 @@ def gps_distance(
250251
def gps_distance(latlon_1: NDArray, latlon_2: NDArray) -> NDArray: ...
251252

252253

253-
def gps_distance(
254-
latlon_1: Union[Sequence[Scalars], NDArray],
255-
latlon_2: Union[Sequence[Scalars], NDArray],
256-
) -> Scalars:
257-
"""
258-
Distance between two (lat,lon) pairs.
259-
260-
>>> p1 = (42.1, -11.1)
261-
>>> p2 = (42.2, -11.3)
262-
>>> 19000 < gps_distance(p1, p2) < 20000
263-
True
264-
"""
265-
x1, y1, z1 = ecef_from_lla(latlon_1[0], latlon_1[1], 0.0)
266-
x2, y2, z2 = ecef_from_lla(latlon_2[0], latlon_2[1], 0.0)
267-
268-
dis = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2)
269-
270-
return dis
271-
272-
273254
class TopocentricConverter:
274255
"""Convert to and from a topocentric reference frame."""
275256

@@ -311,3 +292,108 @@ def to_lla(
311292

312293
def __eq__(self, o: "TopocentricConverter") -> bool:
313294
return np.allclose([self.lat, self.lon, self.alt], (o.lat, o.lon, o.alt))
295+
296+
297+
def construct_proj_transformer(proj_str: str, inverse: bool = False) -> pyproj.Transformer:
298+
"""
299+
Construct a pyproj Transformer object, converting between the given projection antod WGS84 (EPSG:4326).
300+
If inverse is True, the transformation is from WGS84 to the given projection.
301+
"""
302+
crs_4326 = pyproj.CRS.from_epsg(4326)
303+
if inverse:
304+
return pyproj.Transformer.from_proj(crs_4326, pyproj.CRS(proj_str))
305+
else:
306+
return pyproj.Transformer.from_proj(pyproj.CRS(proj_str), crs_4326)
307+
308+
309+
def transform_to_proj(
310+
point: Sequence[float], reference: TopocentricConverter, projection: pyproj.Transformer
311+
) -> List[float]:
312+
"""
313+
Transform a point defined wrt. the local topocentric frame to a projection
314+
defined by the given Transformer. We assume the Transformer goes from
315+
WGS84 to the desired projection.
316+
"""
317+
assert projection.source_crs.to_epsg() == 4326, "Transformer source CRS must be WGS84 (EPSG:4326)"
318+
319+
lat, lon, altitude = reference.to_lla(point[0], point[1], point[2])
320+
easting, northing = projection.transform(lat, lon)
321+
return [easting, northing, altitude]
322+
323+
324+
def get_proj_transform_matrix(
325+
reference: TopocentricConverter, projection: pyproj.Transformer
326+
) -> NDArray:
327+
"""Get the linear transform from reconstruction coords to geocoords."""
328+
p = [[1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 0, 0]]
329+
q = [transform_to_proj(point, reference, projection) for point in p]
330+
331+
transformation = np.array(
332+
[
333+
[q[0][0] - q[3][0], q[1][0] - q[3][0], q[2][0] - q[3][0], q[3][0]],
334+
[q[0][1] - q[3][1], q[1][1] - q[3][1], q[2][1] - q[3][1], q[3][1]],
335+
[q[0][2] - q[3][2], q[1][2] - q[3][2], q[2][2] - q[3][2], q[3][2]],
336+
[0, 0, 0, 1],
337+
]
338+
)
339+
return transformation
340+
341+
342+
def transform_reconstruction_with_matrix(
343+
reconstruction: "types.Reconstruction", transformation: NDArray
344+
) -> None:
345+
"""Apply a rigid transformation to a reconstruction in-place."""
346+
A, b = transformation[:3, :3], transformation[:3, 3]
347+
A1 = np.linalg.inv(A)
348+
349+
for shot in reconstruction.shots.values():
350+
R = shot.pose.get_rotation_matrix()
351+
shot.pose.set_rotation_matrix(np.dot(R, A1))
352+
shot.pose.set_origin(np.dot(A, shot.pose.get_origin()) + b)
353+
354+
for point in reconstruction.points.values():
355+
point.coordinates = list(np.dot(A, point.coordinates) + b)
356+
357+
358+
def transform_reconstruction_with_proj(
359+
reconstruction: "types.Reconstruction", transformation: pyproj.Transformer
360+
) -> None:
361+
"""Apply a proj Transformer to a reconstruction in-place."""
362+
eps = 1e-3
363+
for shot in reconstruction.shots.values():
364+
origin = shot.pose.get_origin()
365+
366+
# Jacobian for rotation update
367+
p0 = np.array(transform_to_proj(origin, reconstruction.reference, transformation))
368+
px = np.array(transform_to_proj(origin + [eps, 0, 0], reconstruction.reference, transformation))
369+
py = np.array(transform_to_proj(origin + [0, eps, 0], reconstruction.reference, transformation))
370+
pz = np.array(transform_to_proj(origin + [0, 0, eps], reconstruction.reference, transformation))
371+
J = np.column_stack(((px - p0) / eps, (py - p0) / eps, (pz - p0) / eps))
372+
373+
shot.pose.set_origin(p0)
374+
shot.pose.set_rotation_matrix(shot.pose.get_rotation_matrix() @ np.linalg.inv(J))
375+
376+
for point in reconstruction.points.values():
377+
point.coordinates = transform_to_proj(
378+
point.coordinates, reconstruction.reference, transformation
379+
)
380+
381+
382+
def gps_distance(
383+
latlon_1: Union[Sequence[Scalars], NDArray],
384+
latlon_2: Union[Sequence[Scalars], NDArray],
385+
) -> Scalars:
386+
"""
387+
Distance between two (lat,lon) pairs.
388+
389+
>>> p1 = (42.1, -11.1)
390+
>>> p2 = (42.2, -11.3)
391+
>>> 19000 < gps_distance(p1, p2) < 20000
392+
True
393+
"""
394+
x1, y1, z1 = ecef_from_lla(latlon_1[0], latlon_1[1], 0.0)
395+
x2, y2, z2 = ecef_from_lla(latlon_2[0], latlon_2[1], 0.0)
396+
397+
dis = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2)
398+
399+
return dis

opensfm/io.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -903,25 +903,36 @@ def _parse_utm_projection_string(line: str) -> str:
903903
return s.format(zone_number, zone_hemisphere)
904904

905905

906-
def _parse_projection(line: str) -> Optional[pyproj.Transformer]:
907-
"""Build a proj4 from the GCP format line."""
908-
crs_4326 = pyproj.CRS.from_epsg(4326)
909-
if line.strip() == "WGS84":
906+
def _parse_projection_string(line: str) -> Optional[str]:
907+
"""Parse the projection string from the GCP format line."""
908+
line = line.strip()
909+
if line == "WGS84":
910910
return None
911911
elif line.upper().startswith("WGS84 UTM"):
912-
return pyproj.Transformer.from_proj(
913-
pyproj.CRS(_parse_utm_projection_string(line)), crs_4326
914-
)
915-
elif "+proj" in line:
916-
return pyproj.Transformer.from_proj(pyproj.CRS(line), crs_4326)
917-
elif line.upper().startswith("EPSG:"):
918-
return pyproj.Transformer.from_proj(
919-
pyproj.CRS.from_epsg(int(line.split(":")[1])), crs_4326
920-
)
912+
return _parse_utm_projection_string(line)
913+
elif "+proj" in line or line.upper().startswith("EPSG:"):
914+
return line
921915
else:
922916
raise ValueError("Un-supported geo system definition: {}".format(line))
923917

924918

919+
def read_gcp_projection_string(fileobj: IO[str]) -> Optional[str]:
920+
"""Read the projection string from a gcp_list.txt file."""
921+
for line in fileobj:
922+
if _valid_gcp_line(line):
923+
return _parse_projection_string(line)
924+
return None
925+
926+
927+
def read_gcp_projection(line: str) -> Optional[pyproj.Transformer]:
928+
"""Build a proj4 from the GCP format line."""
929+
projection_string = _parse_projection_string(line)
930+
if projection_string is None:
931+
return None
932+
933+
return projection_string
934+
935+
925936
def _valid_gcp_line(line: str) -> bool:
926937
stripped = line.strip()
927938
return stripped != "" and stripped[0] != "#"
@@ -937,7 +948,8 @@ def read_gcp_list(
937948
"""
938949
all_lines = fileobj.readlines()
939950
lines = iter(filter(_valid_gcp_line, all_lines))
940-
projection = _parse_projection(next(lines))
951+
projection_string = read_gcp_projection(next(lines))
952+
projection = geo.construct_proj_transformer(projection_string) if projection_string else None
941953
points = _read_gcp_list_lines(lines, projection, exif)
942954
return points
943955

0 commit comments

Comments
 (0)