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 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")
@@ -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