Skip to content

Commit d89ac64

Browse files
authored
Feature fit frame exterior (#28)
* separate cv to oty ext param conversion into _cv_ext_to_oty_ext * add to dos * add fit_frame() * offset coordinates in _cv_ext_to_oty_ext() to fix float32 precision issues * add tests for fit_frame() * add fit_frame_exterior() * add tests for fit_frame_exterior() * fix changing loop var in loop * increment version * add note about fisheye behaviour * better splitting of gcps over images * make fit_frame() & its tests private to avoid a windows OpenCV 4.11 specific issue for now
1 parent 37a44b3 commit d89ac64

File tree

6 files changed

+618
-24
lines changed

6 files changed

+618
-24
lines changed

.github/workflows/install-test-conda-forge.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
- name: Install package
3131
run: |
3232
conda info
33-
conda install orthority>=0.5.1
33+
conda install orthority>=0.6.0
3434
conda list
3535
3636
- name: Install OpenCV Linux dependencies

orthority/camera.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ def pixel_boundary(self, num_pts: int = None) -> np.ndarray:
209209
Boundary pixel (j=column, i=row) coordinates as a 2-by-N array, with (j, i) along the
210210
first dimension.
211211
"""
212+
# TODO: this does not always return the correct number of pts e.g. num_pts=7 / 11
212213

213214
def rect_boundary(im_size: np.ndarray, num_pts: int) -> np.ndarray:
214215
"""Return a rectangular pixel coordinate boundary of ``num_pts`` ~evenly spaced points
@@ -858,6 +859,8 @@ def pixel_to_world_z(self, ji: np.ndarray, z: float | np.ndarray) -> np.ndarray:
858859
"""
859860
# TODO: consider only returning (x, y). the z dimension is redundant, and it is used this
860861
# way in most (all?) places.
862+
# TODO: i have noticed that the results with e.g. z=0 sometimes have z close to but not
863+
# equal 0. is there a way of re-organising this so that doesn't happen?
861864
self._test_init()
862865
self._validate_pixel_coords(ji)
863866
self._validate_z(z, ji)

orthority/fit.py

Lines changed: 250 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,38 @@
1313
# You should have received a copy of the GNU Affero General Public License along with Orthority.
1414
# If not, see <https://www.gnu.org/licenses/>.
1515
"""Camera model fitting and refinement."""
16+
1617
from __future__ import annotations
1718

1819
import logging
20+
import warnings
21+
from collections.abc import Sequence
1922
from copy import deepcopy
20-
from typing import Sequence
23+
from math import ceil
24+
from typing import Any
2125

26+
import cv2
2227
import numpy as np
28+
from rasterio.crs import CRS
2329
from rasterio.rpc import RPC
30+
from rasterio.warp import transform
2431

25-
from orthority.camera import RpcCamera
26-
from orthority.enums import RpcRefine
32+
from orthority import param_io
33+
from orthority.camera import FrameCamera, RpcCamera
34+
from orthority.enums import CameraType, RpcRefine
35+
from orthority.errors import OrthorityWarning
2736

2837
logger = logging.getLogger(__name__)
2938

3039
_default_rpc_refine_method = RpcRefine.shift
3140

41+
_frame_dist_params = {k: v[3:] for k, v in param_io._opt_frame_schema.items()}
42+
"""Distortion coefficient names in OpenCV ordering for each frame camera model."""
43+
_frame_num_params = {k: len(v) + 6 for k, v in _frame_dist_params.items()}
44+
"""Number of distortion coefficient and exterior parameters for each frame camera model (excludes
45+
focal length(s) and principal point).
46+
"""
47+
3248

3349
def refine_rpc(
3450
rpc: RPC | dict, gcps: Sequence[dict], method: RpcRefine = _default_rpc_refine_method
@@ -85,7 +101,7 @@ def _norm_ji(rpc: dict, ji: np.ndarray) -> np.ndarray:
85101
refine_tform[:, -1] = off.mean(axis=1)
86102
else:
87103
for axis in range(2):
88-
ji_rpc_ = np.vstack((ji_rpc[axis], np.ones((ji_rpc.shape[1]))))
104+
ji_rpc_ = np.vstack((ji_rpc[axis], np.ones(ji_rpc.shape[1])))
89105
(m, c), res, rank, s = np.linalg.lstsq(ji_rpc_.T, ji_gcp[axis], rcond=None)
90106
refine_tform[axis, axis] = m
91107
refine_tform[axis, 2] = c
@@ -112,3 +128,233 @@ def _norm_ji(rpc: dict, ji: np.ndarray) -> np.ndarray:
112128
refined_rpc[num_key] += np.array(refined_rpc[den_key]) * refine_tform[axis, 2]
113129
refined_rpc[num_key] = refined_rpc[num_key].tolist()
114130
return refined_rpc
131+
132+
133+
def _gcps_to_cv_coords(
134+
gcp_dict: dict[str, Sequence[dict]], crs: str | CRS | None = None
135+
) -> tuple[list[np.ndarray], list[np.ndarray], np.ndarray]:
136+
"""Convert a GCP dictionary to list of pixel coordinate arrays, a list of world coordinate
137+
arrays and a reference world coordinate position which world coordinate arrays have been
138+
offset relative to.
139+
"""
140+
crs = CRS.from_string(crs) if isinstance(crs, str) else crs
141+
# form lists of pixel and world coordinate arrays
142+
jis = []
143+
xyzs = []
144+
for gcps in gcp_dict.values():
145+
ji = np.array([gcp['ji'] for gcp in gcps])
146+
xyz = np.array([gcp['xyz'] for gcp in gcps])
147+
if crs:
148+
xyz = np.array(transform(CRS.from_epsg(4979), crs, *(xyz.T))).T
149+
jis.append(ji.astype('float32'))
150+
xyzs.append(xyz)
151+
152+
# offset world coordinates and convert to float32
153+
ref_xyz = np.vstack(xyzs).mean(axis=0)
154+
xyzs = [(xyz - ref_xyz).astype('float32') for xyz in xyzs]
155+
return jis, xyzs, ref_xyz
156+
157+
158+
def _fit_frame(
159+
cam_type: CameraType,
160+
im_size: tuple[int, int],
161+
gcp_dict: dict[str, Sequence[dict]],
162+
crs: str | CRS | None = None,
163+
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
164+
"""
165+
Fit a frame camera to GCPs.
166+
167+
:param cam_type:
168+
Camera type to fit.
169+
:param im_size:
170+
Image (width, height) in pixels.
171+
:param gcp_dict:
172+
GCP dictionary e.g. as returned by :func:`~orthority.param_io.read_im_gcps` or
173+
:func:`~orthority.param_io.read_oty_gcps`.
174+
:param crs:
175+
CRS of the camera world coordinate system as an EPSG, proj4 or WKT string,
176+
or :class:`~rasterio.crs.CRS` object. If set to ``None`` (the default), GCPs are assumed
177+
to be in the world coordinate CRS, and are not transformed. Otherwise, GCPs are
178+
transformed from geographic WGS84 coordinates to this CRS if it is supplied.
179+
180+
:return:
181+
Interior parameter and exterior parameter dictionaries.
182+
"""
183+
# TODO: is it better to use cv2.initCameraMatrix2D and cv2.solvePnp(flags=cv2.SOLVEPNP_SQPNP)
184+
# rather than cv2.calibrateCamera when num pts <=4
185+
186+
# check there are at least 4 GCPs per image
187+
min_gcps = min(len(gcps) for gcps in gcp_dict.values())
188+
if min_gcps < 4:
189+
raise ValueError('At least four GCPs are needed per image.')
190+
191+
# check the total number of GCPs is enough to fit cam_type
192+
ttl_gcps = sum(len(gcps) for gcps in gcp_dict.values())
193+
req_gcps = max(4, ceil((1 + _frame_num_params[cam_type]) / 2))
194+
if ttl_gcps < req_gcps:
195+
raise ValueError(
196+
f"A total of at least {req_gcps} GCPs are required to fit the '{cam_type!r}' model."
197+
)
198+
199+
# convert GCPs to OpenCV compatible lists of arrays
200+
jis, xyzs, ref_xyz = _gcps_to_cv_coords(gcp_dict, crs=crs)
201+
202+
# check if GCPs are co-planar (replicates OpenCV's test)
203+
zs = np.vstack([xyz[:, 2] for xyz in xyzs])
204+
z_mean, z_std = np.mean(zs), np.std(zs)
205+
if z_mean > 1e-5 or z_std > 1e-5:
206+
raise ValueError('GCPs should be co-planar to fit interior parameters.')
207+
208+
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 1000, 1e-15)
209+
warn_str = (
210+
"A total of at least {0} GCPs are required to estimate all '{1!r}' parameters, but there "
211+
"are {2}. The initial intrinsic matrix will not be globally optimised."
212+
)
213+
214+
# setup calibration flags & params based on cam_type and number of GCPs
215+
if cam_type is not CameraType.fisheye:
216+
calib_func = cv2.calibrateCamera
217+
# force square pixels always
218+
flags = cv2.CALIB_FIX_ASPECT_RATIO
219+
220+
# fix initial intrinsic matrix if there are not enough GCPs to estimate all params (+3 is
221+
# for 1 focal length and 2 principal points)
222+
req_gcps = ceil((_frame_num_params[cam_type] + 3 + 1) / 2)
223+
if ttl_gcps < req_gcps:
224+
warnings.warn(
225+
warn_str.format(req_gcps, cam_type, ttl_gcps),
226+
category=OrthorityWarning,
227+
stacklevel=2,
228+
)
229+
flags |= cv2.CALIB_FIX_PRINCIPAL_POINT | cv2.CALIB_FIX_FOCAL_LENGTH
230+
231+
if cam_type is CameraType.pinhole:
232+
# fix distortion at zero
233+
flags |= (
234+
cv2.CALIB_ZERO_TANGENT_DIST | cv2.CALIB_FIX_K1 | cv2.CALIB_FIX_K2 | cv2.CALIB_FIX_K3
235+
)
236+
elif cam_type is CameraType.opencv:
237+
# enable full OpenCV model
238+
flags |= cv2.CALIB_RATIONAL_MODEL | cv2.CALIB_THIN_PRISM_MODEL | cv2.CALIB_TILTED_MODEL
239+
240+
else:
241+
calib_func = cv2.fisheye.calibrate
242+
# the oty fisheye camera does not have skew/alpha and CALIB_RECOMPUTE_EXTRINSIC improves
243+
# accuracy
244+
flags = cv2.fisheye.CALIB_FIX_SKEW | cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC
245+
246+
# Fix initial intrinsic matrix if there are not enough GCPs to estimate all params (+4 is
247+
# for 2 focal lengths (you can't fix fisheye aspect ratio) and 2 principal points).
248+
# (Note that cv2.fisheye.calibrate() behaves differently to cv2.fisheye.calibrate(): it
249+
# still runs with ttl_gcps < req_gcps, apparently fixing K and distortion coefficients.)
250+
# TODO: cv2.fisheye.calibrate() seems to require a min of 5 GCPs. confirm & change the
251+
# above check for that, and consider removing the flag changes below which seem to be
252+
# handled internally by cv2.fisheye.calibrate()
253+
req_gcps = ceil((_frame_num_params[cam_type] + 4 + 1) / 2)
254+
if ttl_gcps < req_gcps:
255+
warnings.warn(
256+
warn_str.format(req_gcps, cam_type, ttl_gcps),
257+
category=OrthorityWarning,
258+
stacklevel=2,
259+
)
260+
flags |= cv2.fisheye.CALIB_FIX_PRINCIPAL_POINT | cv2.fisheye.CALIB_FIX_FOCAL_LENGTH
261+
262+
# convert coords to cv2.fisheye format
263+
xyzs = [xyz[None, :] for xyz in xyzs]
264+
jis = [ji[None, :] for ji in jis]
265+
266+
# calibrate
267+
err, K, dist_param, rs, ts = calib_func(
268+
xyzs, jis, im_size, None, None, flags=flags, criteria=criteria
269+
)
270+
logger.debug(
271+
f"RMS reprojection error for fit of '{cam_type}' model to {ttl_gcps} GCPs: {err:.4f}"
272+
)
273+
274+
# convert opencv to oty format interior & exterior params
275+
cam_id = f'{cam_type!r}_fit_to_{ttl_gcps}_gcps'
276+
c_xy = (K[0, 2], K[1, 2]) - (np.array(im_size) - 1) / 2
277+
c_xy /= max(im_size)
278+
dist_param = dict(zip(_frame_dist_params[cam_type], dist_param.squeeze().tolist()))
279+
280+
int_param = dict(
281+
cam_type=cam_type,
282+
im_size=im_size,
283+
focal_len=(K[0, 0], K[1, 1]),
284+
sensor_size=(float(im_size[0]), float(im_size[1])),
285+
cx=c_xy[0],
286+
cy=c_xy[1],
287+
**dist_param,
288+
)
289+
int_param_dict = {cam_id: int_param}
290+
291+
ext_param_dict = {}
292+
for filename, t, r in zip(gcp_dict.keys(), ts, rs):
293+
xyz, opk = param_io._cv_ext_to_oty_ext(t, r, ref_xyz=ref_xyz)
294+
ext_param_dict[filename] = dict(xyz=xyz, opk=opk, camera=cam_id)
295+
296+
return int_param_dict, ext_param_dict
297+
298+
299+
def fit_frame_exterior(
300+
int_param_dict: dict[str, dict[str, Any]],
301+
gcp_dict: dict[str, Sequence[dict]],
302+
crs: str | CRS | None = None,
303+
):
304+
"""
305+
Fit frame camera exterior parameters to GCPs, given the camera's interior parameters.
306+
307+
:param int_param_dict:
308+
Interior parameter dictionary.
309+
:param gcp_dict:
310+
GCP dictionary e.g. as returned by :func:`~orthority.param_io.read_im_gcps` or
311+
:func:`~orthority.param_io.read_oty_gcps`.
312+
:param crs:
313+
CRS of the camera world coordinate system as an EPSG, proj4 or WKT string,
314+
or :class:`~rasterio.crs.CRS` object. If set to ``None`` (the default), GCPs are assumed
315+
to be in the world coordinate CRS, and are not transformed. Otherwise, GCPs are
316+
transformed from geographic WGS84 coordinates to this CRS if it is supplied.
317+
318+
:return:
319+
Exterior parameter dictionary.
320+
"""
321+
if len(int_param_dict) > 1:
322+
warnings.warn(
323+
f"Refining the first of {len(int_param_dict)} cameras defined in the interior "
324+
f"parameter dictionary.",
325+
category=OrthorityWarning,
326+
stacklevel=2,
327+
)
328+
cam_id = next(iter(int_param_dict.keys()))
329+
int_param = next(iter(int_param_dict.values()))
330+
331+
# check there are at least 3 GCPs per image
332+
min_gcps = min(len(gcps) for gcps in gcp_dict.values())
333+
if min_gcps < 3:
334+
raise ValueError('At least three GCPs are needed per image.')
335+
336+
# get initial intrinsic matrix
337+
K = FrameCamera._get_intrinsic(
338+
int_param['im_size'],
339+
int_param['focal_len'],
340+
int_param.get('sensor_size'),
341+
int_param.get('cx', 0.0),
342+
int_param.get('cy', 0.0),
343+
)
344+
345+
# get initial distortion coefficients
346+
dist_names = _frame_dist_params[int_param['cam_type']]
347+
dist_param = [int_param.get(dn, 0.0) for dn in dist_names]
348+
dist_param = np.array(dist_param) if dist_param else None
349+
350+
# convert GCPs to OpenCV compatible lists of arrays
351+
jis, xyzs, ref_xyz = _gcps_to_cv_coords(gcp_dict, crs=crs)
352+
353+
# fit exterior parameters (SOLVEPNP_SQPNP is globally optimal so does not need further refining)
354+
ext_param_dict = {}
355+
for filename, xyz, ji in zip(gcp_dict.keys(), xyzs, jis):
356+
_, r, t = cv2.solvePnP(xyz, ji, K, dist_param, flags=cv2.SOLVEPNP_SQPNP)
357+
xyz_, opk = param_io._cv_ext_to_oty_ext(t, r, ref_xyz=ref_xyz)
358+
ext_param_dict[filename] = dict(xyz=xyz_, opk=opk, camera=cam_id)
359+
360+
return ext_param_dict

orthority/param_io.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ def _read_im_rpc_param(
365365
rpc_param = dict(cam_type=CameraType.rpc, im_size=im_size, rpc=rpc.to_dict())
366366
# TODO: can filename be made to conform to actual case of the filename on the file
367367
# system? otherwise, in windows the user can pass a different case filename here which
368-
# won't macth with GCPs when refining.
368+
# won't match with GCPs when refining.
369369
return {filename: rpc_param}
370370

371371
# read RPC params in a thread pool, populating rpc_param_dict in same order as files
@@ -488,8 +488,8 @@ def _read_im_gcps(
488488
# https://gdal.org/user/raster_data_model.html#gcps. This assumes image GCPs are in
489489
# center of pixel coordinate convention.
490490
oty_gcps = []
491-
for gcp, xyz in zip(gcps, xyz.T):
492-
gcp = dict(ji=(gcp.col, gcp.row), xyz=tuple(xyz.tolist()), id=gcp.id, info=gcp.info)
491+
for gcp, xyz_ in zip(gcps, xyz.T):
492+
gcp = dict(ji=(gcp.col, gcp.row), xyz=tuple(xyz_.tolist()), id=gcp.id, info=gcp.info)
493493
oty_gcps.append(gcp)
494494

495495
return {filename: oty_gcps}
@@ -729,9 +729,9 @@ def _opk_to_rotation(opk: tuple[float, float, float]) -> np.ndarray:
729729
def _rotation_to_opk(R: np.ndarray) -> tuple[float, float, float]:
730730
"""Convert the given rotation matrix to the (omega, phi, kappa) angles in radians."""
731731
# see https://s3.amazonaws.com/mics.pix4d.com/KB/documents/Pix4D_Yaw_Pitch_Roll_Omega_to_Phi_Kappa_angles_and_conversion.pdf
732-
omega = np.arctan2(-R[1, 2], R[2, 2])
733-
phi = np.arcsin(R[0, 2])
734-
kappa = np.arctan2(-R[0, 1], R[0, 0])
732+
omega = float(np.arctan2(-R[1, 2], R[2, 2]))
733+
phi = float(np.arcsin(R[0, 2]))
734+
kappa = float(np.arctan2(-R[0, 1], R[0, 0]))
735735
return omega, phi, kappa
736736

737737

@@ -835,6 +835,27 @@ def _rpy_to_opk(
835835
return omega, phi, kappa
836836

837837

838+
def _cv_ext_to_oty_ext(
839+
t: Sequence[float] | np.ndarray,
840+
r: Sequence[float] | np.ndarray,
841+
ref_xyz: Sequence[float] | np.ndarray | None = None,
842+
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
843+
"""Convert OpenCV / OpenSfM rotation and translation vectors to Orthority format and
844+
convention camera (x, y, z) position and (omega, phi, kappa) angles. Camera positions are
845+
offset by ``ref_xyz`` if it is supplied.
846+
"""
847+
# adapted from ODM: https://github.com/OpenDroneMap/ODM/blob/master/opendm/shots.py
848+
R = cv2.Rodrigues(np.array(r))[0].T
849+
xyz = (-R.dot(t)).squeeze()
850+
if ref_xyz is not None:
851+
xyz += ref_xyz
852+
xyz = tuple(xyz.tolist())
853+
# rotate camera coords from OpenSfM / OpenCV to PATB convention
854+
R_ = R.dot(np.array([[1, 0, 0], [0, -1, 0], [0, 0, -1]]))
855+
opk = _rotation_to_opk(R_)
856+
return xyz, opk
857+
858+
838859
class FrameReader(ABC):
839860
"""
840861
Base frame camera parameter reader.
@@ -1231,14 +1252,10 @@ def read_ext_param(self) -> dict[str, dict[str, Any]]:
12311252

12321253
ext_param_dict = {}
12331254
for filename, shot_dict in self._json_dict['shots'].items():
1234-
# convert reconstruction 'translation' and 'rotation' to oty exterior params,
1235-
# adapted from ODM: https://github.com/OpenDroneMap/ODM/blob/master/opendm/shots.py
1236-
R = cv2.Rodrigues(np.array(shot_dict['rotation']))[0].T
1237-
delta_xyz = -R.dot(shot_dict['translation'])
1238-
xyz = tuple((ref_xyz + delta_xyz).tolist())
1239-
# rotate camera coords from OpenSfM / OpenCV to PATB convention
1240-
R_ = R.dot(np.array([[1, 0, 0], [0, -1, 0], [0, 0, -1]]))
1241-
opk = _rotation_to_opk(R_)
1255+
# convert reconstruction 'translation' and 'rotation' to oty exterior params
1256+
xyz, opk = _cv_ext_to_oty_ext(
1257+
shot_dict['translation'], shot_dict['rotation'], ref_xyz=ref_xyz
1258+
)
12421259
cam_id = shot_dict['camera']
12431260
cam_id = cam_id[3:] if cam_id.startswith('v2 ') else cam_id
12441261
ext_param_dict[filename] = dict(xyz=xyz, opk=opk, camera=cam_id)

orthority/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
# You should have received a copy of the GNU Affero General Public License along with Orthority.
1414
# If not, see <https://www.gnu.org/licenses/>.
1515

16-
__version__ = '0.5.1'
16+
__version__ = '0.6.0'

0 commit comments

Comments
 (0)