13
13
# You should have received a copy of the GNU Affero General Public License along with Orthority.
14
14
# If not, see <https://www.gnu.org/licenses/>.
15
15
"""Camera model fitting and refinement."""
16
+
16
17
from __future__ import annotations
17
18
18
19
import logging
20
+ import warnings
21
+ from collections .abc import Sequence
19
22
from copy import deepcopy
20
- from typing import Sequence
23
+ from math import ceil
24
+ from typing import Any
21
25
26
+ import cv2
22
27
import numpy as np
28
+ from rasterio .crs import CRS
23
29
from rasterio .rpc import RPC
30
+ from rasterio .warp import transform
24
31
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
27
36
28
37
logger = logging .getLogger (__name__ )
29
38
30
39
_default_rpc_refine_method = RpcRefine .shift
31
40
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
+
32
48
33
49
def refine_rpc (
34
50
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:
85
101
refine_tform [:, - 1 ] = off .mean (axis = 1 )
86
102
else :
87
103
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 ])))
89
105
(m , c ), res , rank , s = np .linalg .lstsq (ji_rpc_ .T , ji_gcp [axis ], rcond = None )
90
106
refine_tform [axis , axis ] = m
91
107
refine_tform [axis , 2 ] = c
@@ -112,3 +128,233 @@ def _norm_ji(rpc: dict, ji: np.ndarray) -> np.ndarray:
112
128
refined_rpc [num_key ] += np .array (refined_rpc [den_key ]) * refine_tform [axis , 2 ]
113
129
refined_rpc [num_key ] = refined_rpc [num_key ].tolist ()
114
130
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
0 commit comments