geopyv-dev 0.1.10__cp313-cp313-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geopyv_dev/__init__.py +393 -0
- geopyv_dev/_geopyv_dev.cp313-win_amd64.pyd +0 -0
- geopyv_dev/calibration.py +273 -0
- geopyv_dev/plots.py +911 -0
- geopyv_dev/wrappers.py +61 -0
- geopyv_dev-0.1.10.dist-info/METADATA +23 -0
- geopyv_dev-0.1.10.dist-info/RECORD +10 -0
- geopyv_dev-0.1.10.dist-info/WHEEL +4 -0
- geopyv_dev-0.1.10.dist-info/licenses/LICENSE +674 -0
- geopyv_dev-0.1.10.dist-info/sboms/geopyv-dev-python.cyclonedx.json +7006 -0
geopyv_dev/__init__.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
from ._geopyv_dev import *
|
|
2
|
+
import geopyv_dev._geopyv_dev as _core
|
|
3
|
+
|
|
4
|
+
from .calibration import Calibration, CalibrationParams
|
|
5
|
+
|
|
6
|
+
from .plots import (
|
|
7
|
+
inspect_subset,
|
|
8
|
+
inspect_mesh,
|
|
9
|
+
inspect_sequence,
|
|
10
|
+
inspect_particle,
|
|
11
|
+
inspect_field,
|
|
12
|
+
convergence_subset,
|
|
13
|
+
convergence_mesh,
|
|
14
|
+
convergence_sequence,
|
|
15
|
+
contour_mesh,
|
|
16
|
+
contour_sequence,
|
|
17
|
+
contour_field,
|
|
18
|
+
history_particle,
|
|
19
|
+
history_field,
|
|
20
|
+
trace_particle,
|
|
21
|
+
trace_field,
|
|
22
|
+
standard_error_validation,
|
|
23
|
+
mean_error_validation,
|
|
24
|
+
noise_standard_error_validation,
|
|
25
|
+
noise_mean_error_validation,
|
|
26
|
+
strain_error_validation,
|
|
27
|
+
spatial_error_validation,
|
|
28
|
+
)
|
|
29
|
+
from .wrappers import (
|
|
30
|
+
SubsetWrapper,
|
|
31
|
+
MeshWrapper,
|
|
32
|
+
ParticleWrapper,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Auto-wrap helpers
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def _wrap(raw):
|
|
42
|
+
if isinstance(raw, _core.Subset):
|
|
43
|
+
return Subset._new_from_inner(raw)
|
|
44
|
+
if isinstance(raw, _core.Mesh):
|
|
45
|
+
return Mesh._new_from_inner(raw)
|
|
46
|
+
if isinstance(raw, _core.Sequence):
|
|
47
|
+
return Sequence._new_from_inner(raw)
|
|
48
|
+
if isinstance(raw, _core.Particle):
|
|
49
|
+
return Particle._new_from_inner(raw)
|
|
50
|
+
if isinstance(raw, _core.Field):
|
|
51
|
+
return Field._new_from_inner(raw)
|
|
52
|
+
if isinstance(raw, _core.ParticleSolution):
|
|
53
|
+
return ParticleWrapper(raw)
|
|
54
|
+
return raw
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# IO — shadow _core.save / _core.load
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def save(path, obj):
|
|
62
|
+
"""Serialise a solution object to a .pyv file.
|
|
63
|
+
|
|
64
|
+
Accepts wrapped or raw MeshSolution / FieldSolution / SequenceSolution.
|
|
65
|
+
"""
|
|
66
|
+
_core.save(path, getattr(obj, '_inner', obj))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load(path):
|
|
70
|
+
"""Load a .pyv file and return an auto-wrapped solution object."""
|
|
71
|
+
return _wrap(_core.load(path))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Solve-wrapping classes — shadow the PyO3 originals
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
class Subset:
|
|
79
|
+
"""Reference subset for DIC. Wraps PySubset with inspect/convergence."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, coord, local_mask, f_img, g_img, subset_order=1):
|
|
82
|
+
self._inner = _core.Subset(coord, local_mask, f_img, g_img, subset_order=subset_order)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def _new_from_inner(cls, inner):
|
|
86
|
+
obj = cls.__new__(cls)
|
|
87
|
+
obj._inner = inner
|
|
88
|
+
return obj
|
|
89
|
+
|
|
90
|
+
def __getattr__(self, name):
|
|
91
|
+
return getattr(self._inner, name)
|
|
92
|
+
|
|
93
|
+
def __repr__(self):
|
|
94
|
+
return repr(self._inner)
|
|
95
|
+
|
|
96
|
+
def solve(self, p_0=None, algorithm="icgn",
|
|
97
|
+
max_norm=1e-3, max_iterations=50, tolerance=0.75):
|
|
98
|
+
"""Run the DIC solver. Mutates the object; returns None.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
p_0 : list[float], optional
|
|
103
|
+
Initial warp vector. Defaults to zeros matching order.
|
|
104
|
+
algorithm : str, optional
|
|
105
|
+
"icgn" (default) or "fagn".
|
|
106
|
+
max_norm : float, optional
|
|
107
|
+
Convergence norm threshold. Default 1e-3.
|
|
108
|
+
max_iterations : int, optional
|
|
109
|
+
Iteration limit. Default 50.
|
|
110
|
+
tolerance : float, optional
|
|
111
|
+
Minimum acceptable C_ZNCC for solved=True. Default 0.75.
|
|
112
|
+
"""
|
|
113
|
+
import warnings
|
|
114
|
+
algo = algorithm.lower()
|
|
115
|
+
kwargs = dict(p_0=p_0, max_norm=max_norm,
|
|
116
|
+
max_iterations=max_iterations, tolerance=tolerance)
|
|
117
|
+
if algo == "icgn":
|
|
118
|
+
self._inner.solve_icgn(**kwargs)
|
|
119
|
+
elif algo == "fagn":
|
|
120
|
+
self._inner.solve_fagn(**kwargs)
|
|
121
|
+
else:
|
|
122
|
+
warnings.warn(
|
|
123
|
+
f"Unknown algorithm '{algo}'; falling back to 'icgn'.",
|
|
124
|
+
UserWarning,
|
|
125
|
+
stacklevel=2,
|
|
126
|
+
)
|
|
127
|
+
self._inner.solve_icgn(**kwargs)
|
|
128
|
+
|
|
129
|
+
def save(self, path):
|
|
130
|
+
"""Save the solved subset to a .pyv file."""
|
|
131
|
+
self._inner.save(path)
|
|
132
|
+
|
|
133
|
+
def inspect(self, **kwargs):
|
|
134
|
+
return inspect_subset(self._inner, **kwargs)
|
|
135
|
+
|
|
136
|
+
def convergence(self, **kwargs):
|
|
137
|
+
return convergence_subset(self._inner, **kwargs)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Mesh:
|
|
141
|
+
"""DIC mesh. solve() mutates in place and returns None."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, boundary, target_nodes, f_img, g_img,
|
|
144
|
+
size=(1, 1000), exclusions=None, exclusions_hard=None, mesh_order=2):
|
|
145
|
+
self._inner = _core.Mesh(
|
|
146
|
+
boundary, target_nodes, f_img, g_img,
|
|
147
|
+
size=size, exclusions=exclusions, exclusions_hard=exclusions_hard,
|
|
148
|
+
mesh_order=mesh_order,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def _new_from_inner(cls, inner):
|
|
153
|
+
obj = cls.__new__(cls)
|
|
154
|
+
obj._inner = inner
|
|
155
|
+
return obj
|
|
156
|
+
|
|
157
|
+
def __getattr__(self, name):
|
|
158
|
+
return getattr(self._inner, name)
|
|
159
|
+
|
|
160
|
+
def __repr__(self):
|
|
161
|
+
return repr(self._inner)
|
|
162
|
+
|
|
163
|
+
def solve(self, local_mask, seed_coord, seed_warp=None, **kwargs):
|
|
164
|
+
"""Run the DIC solver. Mutates in place; returns None."""
|
|
165
|
+
self._inner.solve(local_mask, seed_coord, seed_warp=seed_warp, **kwargs)
|
|
166
|
+
|
|
167
|
+
def save(self, path):
|
|
168
|
+
"""Save the solved mesh to a .pyv file."""
|
|
169
|
+
self._inner.save(path)
|
|
170
|
+
|
|
171
|
+
def inspect(self, **kwargs):
|
|
172
|
+
return inspect_mesh(self._inner, **kwargs)
|
|
173
|
+
|
|
174
|
+
def convergence(self, quantity="C_ZNCC", **kwargs):
|
|
175
|
+
return convergence_mesh(self._inner, quantity, **kwargs)
|
|
176
|
+
|
|
177
|
+
def contour(self, quantity, **kwargs):
|
|
178
|
+
return contour_mesh(self._inner, quantity, **kwargs)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Sequence:
|
|
182
|
+
"""Multi-pair DIC sequence. solve() mutates in place and returns None."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, image_dir, boundary, target_nodes,
|
|
185
|
+
size=(1.0, 1000.0), exclusions=None, mesh_order=2):
|
|
186
|
+
self._inner = _core.Sequence(
|
|
187
|
+
image_dir, boundary, target_nodes,
|
|
188
|
+
size=size, exclusions=exclusions, mesh_order=mesh_order,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def _new_from_inner(cls, inner):
|
|
193
|
+
obj = cls.__new__(cls)
|
|
194
|
+
obj._inner = inner
|
|
195
|
+
return obj
|
|
196
|
+
|
|
197
|
+
def __getattr__(self, name):
|
|
198
|
+
return getattr(self._inner, name)
|
|
199
|
+
|
|
200
|
+
def __repr__(self):
|
|
201
|
+
return repr(self._inner)
|
|
202
|
+
|
|
203
|
+
def solve(self, *args, **kwargs):
|
|
204
|
+
self._inner.solve(*args, **kwargs)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def mesh_solutions(self):
|
|
208
|
+
return [MeshWrapper(m) for m in self._inner.mesh_solutions]
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def meshes(self):
|
|
212
|
+
return self.mesh_solutions
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def solved(self):
|
|
216
|
+
return self._inner.solved
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def unsolvable(self):
|
|
220
|
+
return self._inner.unsolvable
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def override_log(self):
|
|
224
|
+
return self._inner.override_log
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def reference_updates(self):
|
|
228
|
+
return self._inner.reference_updates
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def mesh_paths(self):
|
|
232
|
+
return self._inner.mesh_paths
|
|
233
|
+
|
|
234
|
+
def mesh_solution_at(self, idx):
|
|
235
|
+
return MeshWrapper(self._inner.mesh_solution_at(idx))
|
|
236
|
+
|
|
237
|
+
def all_c_zncc(self):
|
|
238
|
+
return self._inner.all_c_zncc()
|
|
239
|
+
|
|
240
|
+
def inspect(self, mesh_idx, subset_idx=None, **kwargs):
|
|
241
|
+
return inspect_sequence(self, mesh_idx=mesh_idx, subset_idx=subset_idx, **kwargs)
|
|
242
|
+
|
|
243
|
+
def convergence(self, mesh_idx=None, quantity="C_ZNCC", **kwargs):
|
|
244
|
+
return convergence_sequence(self, mesh_idx=mesh_idx, quantity=quantity, **kwargs)
|
|
245
|
+
|
|
246
|
+
def contour(self, quantity, mesh_idx, **kwargs):
|
|
247
|
+
return contour_sequence(self, mesh_idx=mesh_idx, quantity=quantity, **kwargs)
|
|
248
|
+
|
|
249
|
+
def save(self, path):
|
|
250
|
+
_core.save(path, self._inner)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class Field:
|
|
254
|
+
"""Particle field built from a solved Sequence. solve() mutates in place and returns None."""
|
|
255
|
+
|
|
256
|
+
def __init__(self, sequence_solution, track=True, depth=1.0,
|
|
257
|
+
coordinates=None, volumes=None):
|
|
258
|
+
raw = getattr(sequence_solution, '_inner', sequence_solution)
|
|
259
|
+
self._inner = _core.Field(raw, track=track, depth=depth,
|
|
260
|
+
coordinates=coordinates, volumes=volumes)
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def _new_from_inner(cls, inner):
|
|
264
|
+
obj = cls.__new__(cls)
|
|
265
|
+
obj._inner = inner
|
|
266
|
+
return obj
|
|
267
|
+
|
|
268
|
+
def __getattr__(self, name):
|
|
269
|
+
return getattr(self._inner, name)
|
|
270
|
+
|
|
271
|
+
def __repr__(self):
|
|
272
|
+
return repr(self._inner)
|
|
273
|
+
|
|
274
|
+
def solve(self, factor=0.0, true_incs=True, calibration=None):
|
|
275
|
+
cal = getattr(calibration, '_inner', calibration)
|
|
276
|
+
self._inner.solve(factor=factor, true_incs=true_incs, calibration=cal)
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def particles(self):
|
|
280
|
+
return [ParticleWrapper(p) for p in self._inner.particles]
|
|
281
|
+
|
|
282
|
+
def inspect(self, **kwargs):
|
|
283
|
+
return inspect_field(self, **kwargs)
|
|
284
|
+
|
|
285
|
+
def contour(self, quantity, **kwargs):
|
|
286
|
+
return contour_field(self, quantity, **kwargs)
|
|
287
|
+
|
|
288
|
+
def history(self, particle_index, quantity="warps", **kwargs):
|
|
289
|
+
return history_field(self, particle_index, quantity, **kwargs)
|
|
290
|
+
|
|
291
|
+
def trace(self, quantity="warps", component=0, **kwargs):
|
|
292
|
+
return trace_field(self, quantity, component, **kwargs)
|
|
293
|
+
|
|
294
|
+
def save(self, path):
|
|
295
|
+
_core.save(path, self._inner)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class Particle:
|
|
299
|
+
"""Lagrangian/Eulerian particle. solve() mutates in place and returns None."""
|
|
300
|
+
|
|
301
|
+
def __init__(self, source, coordinate, initial_warp=None, track=True):
|
|
302
|
+
raw = getattr(source, '_inner', source)
|
|
303
|
+
self._inner = _core.Particle(raw, coordinate, initial_warp=initial_warp, track=track)
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def _new_from_inner(cls, inner):
|
|
307
|
+
obj = cls.__new__(cls)
|
|
308
|
+
obj._inner = inner
|
|
309
|
+
return obj
|
|
310
|
+
|
|
311
|
+
def __getattr__(self, name):
|
|
312
|
+
return getattr(self._inner, name)
|
|
313
|
+
|
|
314
|
+
def __repr__(self):
|
|
315
|
+
return repr(self._inner)
|
|
316
|
+
|
|
317
|
+
def solve(self, factor=0.0, true_incs=True, calibration=None):
|
|
318
|
+
cal = getattr(calibration, '_inner', calibration)
|
|
319
|
+
self._inner.solve(factor=factor, true_incs=true_incs, calibration=cal)
|
|
320
|
+
|
|
321
|
+
def solve_increment(self, m):
|
|
322
|
+
return self._inner.solve_increment(m)
|
|
323
|
+
|
|
324
|
+
def inspect(self, **kwargs):
|
|
325
|
+
return inspect_particle(self._inner, **kwargs)
|
|
326
|
+
|
|
327
|
+
def history(self, quantity="warps", **kwargs):
|
|
328
|
+
return history_particle(self._inner, quantity, **kwargs)
|
|
329
|
+
|
|
330
|
+
def trace(self, quantity="warps", component=0, **kwargs):
|
|
331
|
+
return trace_particle(self._inner, quantity, component, **kwargs)
|
|
332
|
+
|
|
333
|
+
def save(self, path):
|
|
334
|
+
_core.save(path, self._inner)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class Validation:
|
|
338
|
+
"""Validation of DIC results against ground-truth Speckle warps."""
|
|
339
|
+
|
|
340
|
+
def __init__(self, speckle, fields, labels):
|
|
341
|
+
raw_speckle = getattr(speckle, '_inner', speckle)
|
|
342
|
+
raw_fields = [getattr(f, '_inner', f) for f in fields]
|
|
343
|
+
self._inner = _core.Validation(raw_speckle, raw_fields, labels)
|
|
344
|
+
self._solution = None
|
|
345
|
+
|
|
346
|
+
def __getattr__(self, name):
|
|
347
|
+
return getattr(self._inner, name)
|
|
348
|
+
|
|
349
|
+
def solve(self, cumulative=True, skim=None):
|
|
350
|
+
self._solution = self._inner.solve(cumulative=cumulative, skim=skim)
|
|
351
|
+
|
|
352
|
+
def standard_error(self, component, **kwargs):
|
|
353
|
+
self._check_solved()
|
|
354
|
+
return standard_error_validation(self._solution, component, **kwargs)
|
|
355
|
+
|
|
356
|
+
def mean_error(self, component, **kwargs):
|
|
357
|
+
self._check_solved()
|
|
358
|
+
return mean_error_validation(self._solution, component, **kwargs)
|
|
359
|
+
|
|
360
|
+
def noise_standard_error(self, component, **kwargs):
|
|
361
|
+
self._check_solved()
|
|
362
|
+
return noise_standard_error_validation(self._solution, component, **kwargs)
|
|
363
|
+
|
|
364
|
+
def noise_mean_error(self, component, **kwargs):
|
|
365
|
+
self._check_solved()
|
|
366
|
+
return noise_mean_error_validation(self._solution, component, **kwargs)
|
|
367
|
+
|
|
368
|
+
def strain_error(self, **kwargs):
|
|
369
|
+
self._check_solved()
|
|
370
|
+
return strain_error_validation(self._solution, **kwargs)
|
|
371
|
+
|
|
372
|
+
def spatial_error(self, field_index, time_index, **kwargs):
|
|
373
|
+
self._check_solved()
|
|
374
|
+
return spatial_error_validation(self._solution, field_index, time_index, **kwargs)
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def solution(self):
|
|
378
|
+
self._check_solved()
|
|
379
|
+
return self._solution
|
|
380
|
+
|
|
381
|
+
def n_frames(self, field_index=0):
|
|
382
|
+
self._check_solved()
|
|
383
|
+
import numpy as np
|
|
384
|
+
return np.asarray(self._solution.fields[field_index].applied).shape[0]
|
|
385
|
+
|
|
386
|
+
def n_particles(self, field_index=0):
|
|
387
|
+
self._check_solved()
|
|
388
|
+
import numpy as np
|
|
389
|
+
return np.asarray(self._solution.fields[field_index].applied).shape[1]
|
|
390
|
+
|
|
391
|
+
def _check_solved(self):
|
|
392
|
+
if self._solution is None:
|
|
393
|
+
raise RuntimeError("call solve() first")
|
|
Binary file
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import glob
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
import geopyv_dev._geopyv_dev as _core
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CalibrationParams:
|
|
13
|
+
"""Thin Python wrapper around the Rust CalibrationParams.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
intmat : array-like (3, 3)
|
|
18
|
+
Camera intrinsic matrix.
|
|
19
|
+
extmat : array-like (4, 4)
|
|
20
|
+
Extrinsic matrix for the chosen object plane.
|
|
21
|
+
dist : array-like (5,)
|
|
22
|
+
Distortion coefficients [k1, k2, p1, p2, k3].
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, intmat, extmat, dist):
|
|
26
|
+
self._inner = _core.CalibrationParams(
|
|
27
|
+
np.asarray(intmat, dtype=np.float64),
|
|
28
|
+
np.asarray(extmat, dtype=np.float64),
|
|
29
|
+
np.asarray(dist, dtype=np.float64),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def o2i(self, objpnts):
|
|
33
|
+
return self._inner.o2i(np.asarray(objpnts, dtype=np.float64))
|
|
34
|
+
|
|
35
|
+
def i2o(self, imgpnts):
|
|
36
|
+
return self._inner.i2o(np.asarray(imgpnts, dtype=np.float64))
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def intmat(self):
|
|
40
|
+
return self._inner.intmat
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def extmat(self):
|
|
44
|
+
return self._inner.extmat
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def dist(self):
|
|
48
|
+
return self._inner.dist
|
|
49
|
+
|
|
50
|
+
def __repr__(self):
|
|
51
|
+
return repr(self._inner)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Calibration:
|
|
55
|
+
"""Camera calibration using a ChArUco board and OpenCV.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
calibration_dir : str
|
|
60
|
+
Directory containing calibration images.
|
|
61
|
+
common_name : str, optional
|
|
62
|
+
Filename prefix for the calibration images.
|
|
63
|
+
file_format : str, optional
|
|
64
|
+
Image extension (default '.jpg').
|
|
65
|
+
dictionary : cv2.aruco.Dictionary, optional
|
|
66
|
+
ArUco dictionary to use.
|
|
67
|
+
board_parameters : tuple (columns, rows, square_length, marker_length)
|
|
68
|
+
ChArUco board specification.
|
|
69
|
+
show : bool, optional
|
|
70
|
+
Display the board pattern at initialisation.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
calibration_dir=".",
|
|
77
|
+
common_name="",
|
|
78
|
+
file_format=".jpg",
|
|
79
|
+
dictionary=None,
|
|
80
|
+
board_parameters=None,
|
|
81
|
+
show=False,
|
|
82
|
+
):
|
|
83
|
+
try:
|
|
84
|
+
import cv2
|
|
85
|
+
from cv2 import aruco
|
|
86
|
+
except ImportError as exc:
|
|
87
|
+
raise ImportError("opencv-python is required for Calibration") from exc
|
|
88
|
+
|
|
89
|
+
if not calibration_dir.endswith("/"):
|
|
90
|
+
calibration_dir += "/"
|
|
91
|
+
if not file_format.startswith("."):
|
|
92
|
+
file_format = "." + file_format
|
|
93
|
+
|
|
94
|
+
self._calibration_dir = calibration_dir
|
|
95
|
+
self._common_name = common_name
|
|
96
|
+
self._file_format = file_format
|
|
97
|
+
self._dictionary = (
|
|
98
|
+
dictionary
|
|
99
|
+
if dictionary is not None
|
|
100
|
+
else aruco.getPredefinedDictionary(cv2.aruco.DICT_5X5_1000)
|
|
101
|
+
)
|
|
102
|
+
self.solved = False
|
|
103
|
+
self.params = None
|
|
104
|
+
|
|
105
|
+
columns, rows, square_length, marker_length = board_parameters
|
|
106
|
+
self._board = aruco.CharucoBoard(
|
|
107
|
+
(columns, rows), square_length, marker_length, self._dictionary
|
|
108
|
+
)
|
|
109
|
+
self._objpnts = self._board.getChessboardCorners()
|
|
110
|
+
|
|
111
|
+
if show:
|
|
112
|
+
import matplotlib.pyplot as plt
|
|
113
|
+
imboard = self._board.generateImage((290, 180))
|
|
114
|
+
fig, ax = plt.subplots()
|
|
115
|
+
ax.imshow(imboard, interpolation="nearest")
|
|
116
|
+
ax.axis("off")
|
|
117
|
+
plt.show()
|
|
118
|
+
|
|
119
|
+
pattern = calibration_dir + common_name + "*" + file_format
|
|
120
|
+
self._calibration_images = sorted(glob.glob(pattern))
|
|
121
|
+
if not self._calibration_images:
|
|
122
|
+
raise FileNotFoundError(f"No images found matching {pattern!r}")
|
|
123
|
+
if len(self._calibration_images) < 10:
|
|
124
|
+
log.warning(
|
|
125
|
+
"%d images found; at least 10 are recommended.",
|
|
126
|
+
len(self._calibration_images),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
img0 = cv2.imread(self._calibration_images[0])
|
|
130
|
+
self._image_size = img0.shape[:2]
|
|
131
|
+
|
|
132
|
+
def solve(self, *, ext_id=None, binary_threshold=None, acceptance_threshold=50):
|
|
133
|
+
"""Run the ArUco/ChArUco calibration pipeline.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
ext_id : int, optional
|
|
138
|
+
Numeric suffix of the calibration image to use as the object plane.
|
|
139
|
+
Defaults to the last accepted image.
|
|
140
|
+
binary_threshold : float, optional
|
|
141
|
+
Pixel threshold for binarisation before corner detection.
|
|
142
|
+
acceptance_threshold : int, optional
|
|
143
|
+
Minimum ChArUco corners for a frame to be accepted (default 50).
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
import cv2
|
|
147
|
+
except ImportError as exc:
|
|
148
|
+
raise ImportError("opencv-python is required for Calibration.solve") from exc
|
|
149
|
+
|
|
150
|
+
self._binary_threshold = binary_threshold
|
|
151
|
+
self._ext_id = ext_id
|
|
152
|
+
|
|
153
|
+
allCorners, allIds, imsize = self._read_chessboards(acceptance_threshold)
|
|
154
|
+
if not allCorners:
|
|
155
|
+
raise RuntimeError("Calibration failed: no usable frames detected.")
|
|
156
|
+
|
|
157
|
+
self._calibrate_camera(allCorners, allIds, imsize)
|
|
158
|
+
self._extrinsic_matrix_generator()
|
|
159
|
+
self.solved = True
|
|
160
|
+
|
|
161
|
+
self._dist = self._dist.flatten()
|
|
162
|
+
self.params = CalibrationParams(self._intmat, self._extmat, self._dist)
|
|
163
|
+
|
|
164
|
+
def _read_chessboards(self, acceptance_threshold):
|
|
165
|
+
import cv2
|
|
166
|
+
|
|
167
|
+
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-5)
|
|
168
|
+
allCorners, allIds, accepted = [], [], []
|
|
169
|
+
|
|
170
|
+
for path in self._calibration_images:
|
|
171
|
+
frame = cv2.imread(path)
|
|
172
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
173
|
+
gray = cv2.GaussianBlur(gray, (5, 5), 0)
|
|
174
|
+
|
|
175
|
+
if self._binary_threshold is not None:
|
|
176
|
+
_, src = cv2.threshold(gray, self._binary_threshold, 255, cv2.THRESH_BINARY)
|
|
177
|
+
else:
|
|
178
|
+
src = gray
|
|
179
|
+
|
|
180
|
+
arcnrs, arids, _ = cv2.aruco.detectMarkers(
|
|
181
|
+
src, self._dictionary, parameters=cv2.aruco.DetectorParameters()
|
|
182
|
+
)
|
|
183
|
+
if not arcnrs:
|
|
184
|
+
continue
|
|
185
|
+
for c in arcnrs:
|
|
186
|
+
cv2.cornerSubPix(gray, c, (3, 3), (-1, -1), criteria)
|
|
187
|
+
ret, chcnrs, chids = cv2.aruco.interpolateCornersCharuco(
|
|
188
|
+
arcnrs, arids, gray, self._board
|
|
189
|
+
)
|
|
190
|
+
if chcnrs is not None and chids is not None and len(chcnrs) > acceptance_threshold:
|
|
191
|
+
allCorners.append(chcnrs)
|
|
192
|
+
allIds.append(chids)
|
|
193
|
+
accepted.append(path)
|
|
194
|
+
|
|
195
|
+
self._accepted_images = accepted
|
|
196
|
+
# Determine extrinsic image index
|
|
197
|
+
if self._ext_id is not None:
|
|
198
|
+
indices = [int(re.findall(r"\d+", p)[-1]) for p in accepted]
|
|
199
|
+
try:
|
|
200
|
+
self._index = indices.index(self._ext_id)
|
|
201
|
+
except ValueError:
|
|
202
|
+
log.warning("ext_id %s not found in accepted images; using last.", self._ext_id)
|
|
203
|
+
self._index = len(accepted) - 1
|
|
204
|
+
else:
|
|
205
|
+
self._index = len(accepted) - 1
|
|
206
|
+
|
|
207
|
+
imsize = cv2.imread(accepted[0], cv2.IMREAD_GRAYSCALE).shape if accepted else (0, 0)
|
|
208
|
+
return allCorners, allIds, imsize
|
|
209
|
+
|
|
210
|
+
def _calibrate_camera(self, allCorners, allIds, imsize):
|
|
211
|
+
import cv2
|
|
212
|
+
|
|
213
|
+
h, w = imsize
|
|
214
|
+
cam_init = np.array([[1000., 0., w / 2.], [0., 1000., h / 2.], [0., 0., 1.]])
|
|
215
|
+
dist_init = np.zeros((5, 1))
|
|
216
|
+
_, self._intmat, self._dist, self._rot, self._trans = (
|
|
217
|
+
cv2.aruco.calibrateCameraCharuco(
|
|
218
|
+
charucoCorners=allCorners,
|
|
219
|
+
charucoIds=allIds,
|
|
220
|
+
board=self._board,
|
|
221
|
+
imageSize=(w, h),
|
|
222
|
+
cameraMatrix=cam_init,
|
|
223
|
+
distCoeffs=dist_init,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
self._rot = np.asarray(self._rot)
|
|
227
|
+
self._trans = np.asarray(self._trans)
|
|
228
|
+
|
|
229
|
+
def _extrinsic_matrix_generator(self):
|
|
230
|
+
import cv2
|
|
231
|
+
|
|
232
|
+
n = len(self._rot)
|
|
233
|
+
extmats = np.zeros((n, 4, 4))
|
|
234
|
+
extmats[:, 3, 3] = 1.0
|
|
235
|
+
extmats[:, :3, 3] = self._trans[:, :3].reshape(-1, 3)
|
|
236
|
+
for i in range(n):
|
|
237
|
+
extmats[i, :3, :3], _ = cv2.Rodrigues(self._rot[i])
|
|
238
|
+
self._extmats = extmats
|
|
239
|
+
self._extmat = extmats[self._index]
|
|
240
|
+
|
|
241
|
+
def calibrate(self, obj, override=False):
|
|
242
|
+
"""Calibrate a Region in-place by mapping its nodes to object space.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
obj : CircleRegion or PathRegion
|
|
247
|
+
The region to calibrate.
|
|
248
|
+
override : bool, optional
|
|
249
|
+
Re-calibrate even if already marked calibrated.
|
|
250
|
+
"""
|
|
251
|
+
if not self.solved or self.params is None:
|
|
252
|
+
raise RuntimeError("Call solve() before calibrate().")
|
|
253
|
+
|
|
254
|
+
region_types = (_core.CircleRegion, _core.PathRegion)
|
|
255
|
+
if not isinstance(obj, region_types):
|
|
256
|
+
raise TypeError(
|
|
257
|
+
f"calibrate() only supports CircleRegion / PathRegion; got {type(obj).__name__}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if obj.calibrated and not override:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
obj.current_nodes = self.params.i2o(obj.current_nodes)
|
|
264
|
+
obj.history_nodes = [self.params.i2o(n) for n in obj.history_nodes]
|
|
265
|
+
|
|
266
|
+
c = self.params.i2o(np.array([obj.current_centre]))
|
|
267
|
+
obj.current_centre = tuple(c[0].tolist())
|
|
268
|
+
|
|
269
|
+
obj.history_centres = [
|
|
270
|
+
tuple(self.params.i2o(np.array([ctr]))[0].tolist())
|
|
271
|
+
for ctr in obj.history_centres
|
|
272
|
+
]
|
|
273
|
+
obj.calibrated = True
|