InterpolatePy 1.0.0__py3-none-any.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.
@@ -0,0 +1,544 @@
1
+ import numpy as np
2
+
3
+ from interpolatepy.b_spline import BSpline
4
+
5
+
6
+ class ApproximationBSpline(BSpline):
7
+ """A class for B-spline curve approximation of a set of points.
8
+
9
+ Inherits from BSpline class.
10
+
11
+ The approximation follows the theory described in Section 8.5 of the reference:
12
+ - The end points are exactly interpolated
13
+ - The internal points are approximated in the least squares sense
14
+ - Degree 3 (cubic) is typically used to ensure C2 continuity
15
+
16
+ Attributes
17
+ ----------
18
+ original_points : np.ndarray
19
+ The original points being approximated.
20
+ original_parameters : np.ndarray
21
+ The parameter values corresponding to original points.
22
+ """
23
+
24
+ def __init__( # noqa: PLR0913
25
+ self,
26
+ points: list | np.ndarray,
27
+ num_control_points: int,
28
+ *, # Make remaining parameters keyword-only
29
+ degree: int = 3,
30
+ weights: list | np.ndarray | None = None,
31
+ method: str = "chord_length",
32
+ debug: bool = False,
33
+ ) -> None:
34
+ """Initialize an approximation B-spline.
35
+
36
+ Parameters
37
+ ----------
38
+ points : list or np.ndarray
39
+ The points to approximate.
40
+ num_control_points : int
41
+ The number of control points to use.
42
+ degree : int, default=3
43
+ The degree of the B-spline. Defaults to 3 for cubic.
44
+ weights : list or np.ndarray or None, default=None
45
+ Weights for points in approximation. If None, uniform weighting is used.
46
+ method : str, default="chord_length"
47
+ Method for parameter calculation. Options are 'equally_spaced',
48
+ 'chord_length', or 'centripetal'.
49
+ debug : bool, default=False
50
+ Whether to print debug information.
51
+
52
+ Raises
53
+ ------
54
+ ValueError
55
+ If inputs do not satisfy approximation requirements.
56
+ """
57
+ # Validate inputs
58
+ if degree < 1:
59
+ raise ValueError("Degree must be at least 1")
60
+ if num_control_points <= degree:
61
+ raise ValueError("Number of control points must be greater than the degree")
62
+ if len(points) <= num_control_points:
63
+ raise ValueError("Number of points must be greater than number of control points")
64
+
65
+ # Store debug flag
66
+ self.debug = debug
67
+
68
+ if self.debug:
69
+ print("\n" + "=" * 50)
70
+ print("INITIALIZING APPROXIMATION B-SPLINE")
71
+ print(f"Degree: {degree}")
72
+ print(f"Number of control points: {num_control_points}")
73
+ print(f"Number of points to approximate: {len(points)}")
74
+ print(f"Parameterization method: {method}")
75
+ print("=" * 50)
76
+
77
+ # Convert points to numpy array if needed
78
+ if not isinstance(points, np.ndarray):
79
+ points = np.array(points, dtype=np.float64)
80
+
81
+ # Set default weights if not provided (uniform weights)
82
+ n = len(points) - 1 # Number of points minus 1
83
+ if weights is None:
84
+ weights = np.ones(n - 1) # Exclude first and last points
85
+ elif not isinstance(weights, np.ndarray):
86
+ weights = np.array(weights, dtype=np.float64)
87
+
88
+ # Calculate parameter values for the points
89
+ u_bar = self._compute_parameters(points, method)
90
+
91
+ # Calculate knot vector
92
+ knots = self._compute_knots(degree, num_control_points, len(points), u_bar)
93
+
94
+ # Calculate control points using least squares approximation
95
+ control_points = self._approximate_control_points(
96
+ points, degree, knots, u_bar, num_control_points, weights
97
+ )
98
+
99
+ # Initialize parent class with calculated values
100
+ super().__init__(degree, knots, control_points)
101
+
102
+ # Store the original points and parameter values for reference
103
+ self.original_points = points
104
+ self.original_parameters = u_bar
105
+
106
+ if self.debug:
107
+ print("\nFINAL RESULTS:")
108
+ print(f"Degree: {degree}")
109
+ print(f"Number of control points: {len(control_points)}")
110
+ print(f"Knot vector: {knots}")
111
+ print("\nControl points:")
112
+ for i, cp in enumerate(control_points):
113
+ print(f" P{i}: {cp}")
114
+
115
+ def _compute_parameters(self, points: np.ndarray, method: str = "chord_length") -> np.ndarray:
116
+ """Calculate parameter values using one of three methods.
117
+
118
+ Parameters
119
+ ----------
120
+ points : np.ndarray
121
+ The points to approximate.
122
+ method : str, default="chord_length"
123
+ Method for calculating the parameters. Options are 'equally_spaced',
124
+ 'chord_length', or 'centripetal'.
125
+
126
+ Returns
127
+ -------
128
+ np.ndarray
129
+ Parameter values for each point, normalized to [0, 1].
130
+
131
+ Raises
132
+ ------
133
+ ValueError
134
+ If an unknown method is provided.
135
+ """
136
+ n = len(points) - 1 # Index of the last point
137
+
138
+ # Initialize the parameters
139
+ u_bar = np.zeros(n + 1, dtype=np.float64)
140
+
141
+ # Set the endpoints
142
+ u_bar[0] = 0.0
143
+ u_bar[n] = 1.0
144
+
145
+ if method == "equally_spaced":
146
+ # Equally spaced parameters
147
+ for k in range(1, n):
148
+ u_bar[k] = k / n
149
+
150
+ elif method == "chord_length":
151
+ # Chord length distribution
152
+ # Calculate total chord length
153
+ total_length = 0.0
154
+ for k in range(1, n + 1):
155
+ total_length += np.linalg.norm(points[k] - points[k - 1])
156
+
157
+ # Calculate parameters
158
+ for k in range(1, n):
159
+ u_bar[k] = u_bar[k - 1] + np.linalg.norm(points[k] - points[k - 1]) / total_length
160
+
161
+ elif method == "centripetal":
162
+ # Centripetal distribution
163
+ mu = 0.5 # As recommended in the document
164
+
165
+ # Calculate total "centripetal" length
166
+ total_length = 0.0
167
+ for k in range(1, n + 1):
168
+ total_length += np.linalg.norm(points[k] - points[k - 1]) ** mu
169
+
170
+ # Calculate parameters
171
+ for k in range(1, n):
172
+ u_bar[k] = (
173
+ u_bar[k - 1] + np.linalg.norm(points[k] - points[k - 1]) ** mu / total_length
174
+ )
175
+
176
+ else:
177
+ raise ValueError(
178
+ f"Unknown method: {method}. Options are 'equally_spaced', "
179
+ f"'chord_length', or 'centripetal'."
180
+ )
181
+
182
+ if hasattr(self, "debug") and self.debug:
183
+ print(f"\nPARAMETER VALUES (using '{method}' method):")
184
+ for i, u in enumerate(u_bar):
185
+ print(f" u_bar[{i}] = {u:.6f}")
186
+
187
+ return u_bar
188
+
189
+ def _compute_knots(
190
+ self, degree: int, num_control_points: int, num_points: int, u_bar: np.ndarray
191
+ ) -> np.ndarray:
192
+ """Compute knot vector following the algorithm in Section 8.5.1.
193
+
194
+ Parameters
195
+ ----------
196
+ degree : int
197
+ The degree of the B-spline.
198
+ num_control_points : int
199
+ The number of control points.
200
+ num_points : int
201
+ The number of points to approximate.
202
+ u_bar : np.ndarray
203
+ Parameter values for the points.
204
+
205
+ Returns
206
+ -------
207
+ np.ndarray
208
+ The knot vector.
209
+ """
210
+ # Total number of knots
211
+ num_knots = num_control_points + degree + 1
212
+
213
+ if hasattr(self, "debug") and self.debug:
214
+ print("\nKNOT VECTOR CALCULATION:")
215
+ print(f" Number of knots needed: {num_knots}")
216
+
217
+ # Initialize knot vector
218
+ knots = np.zeros(num_knots)
219
+
220
+ # Set the first and last knots with multiplicity p+1 to ensure interpolation
221
+ # of end points as specified in the document
222
+ knots[: degree + 1] = u_bar[0]
223
+ knots[-(degree + 1) :] = u_bar[-1]
224
+
225
+ # Compute internal knots using the method from Section 8.5.1
226
+ n = num_points - 1 # Number of points minus 1
227
+ m = num_control_points - 1 # Number of control points minus 1
228
+
229
+ # Formula from the document: d = (n+1)/(m-p+1)
230
+ d = (n + 1) / (m - degree + 1)
231
+
232
+ if hasattr(self, "debug") and self.debug:
233
+ print(f" d = (n+1)/(m-p+1) = ({n + 1})/({m}-{degree}+1) = {d:.6f}")
234
+
235
+ # Compute internal knots using the algorithm from the document
236
+ # For j=1,...,m-p compute:
237
+ # i = floor(j*d)
238
+ # a = j*d - i
239
+ # u_(j+p) = (1-a)ū_(i-1) + aū_i
240
+ for j in range(1, m - degree + 1):
241
+ i = int(j * d) # floor(j*d)
242
+ alpha = j * d - i # j*d - floor(j*d)
243
+ knots[j + degree] = (1 - alpha) * u_bar[i - 1] + alpha * u_bar[i]
244
+
245
+ if hasattr(self, "debug") and self.debug:
246
+ print(f" j={j}, i=floor({j}*{d:.6f})={i}, a={alpha:.6f}")
247
+ print(
248
+ f" u_{j + degree} = (1-{alpha:.6f})*{u_bar[i - 1]:.6f} + "
249
+ f"{alpha:.6f}*{u_bar[i]:.6f} = {knots[j + degree]:.6f}"
250
+ )
251
+
252
+ if hasattr(self, "debug") and self.debug:
253
+ print("\nFINAL KNOT VECTOR:")
254
+ knot_str = " ["
255
+ for k in knots:
256
+ knot_str += f"{k:.6f}, "
257
+ knot_str = knot_str[:-2] + "]"
258
+ print(knot_str)
259
+
260
+ return knots
261
+
262
+ def _approximate_control_points( # noqa: PLR0917, PLR0913
263
+ self,
264
+ points: np.ndarray,
265
+ degree: int,
266
+ knots: np.ndarray,
267
+ u_bar: np.ndarray,
268
+ num_control_points: int,
269
+ weights: np.ndarray,
270
+ ) -> np.ndarray:
271
+ """Compute control points using least squares approximation.
272
+
273
+ Uses the approach described in Section 8.5.
274
+
275
+ Parameters
276
+ ----------
277
+ points : np.ndarray
278
+ The points to approximate.
279
+ degree : int
280
+ The degree of the B-spline.
281
+ knots : np.ndarray
282
+ The knot vector.
283
+ u_bar : np.ndarray
284
+ Parameter values for the points.
285
+ num_control_points : int
286
+ The number of control points.
287
+ weights : np.ndarray
288
+ Weights for points in approximation.
289
+
290
+ Returns
291
+ -------
292
+ np.ndarray
293
+ The control points.
294
+ """
295
+ n = len(points) - 1 # Number of points minus 1
296
+ m = num_control_points - 1 # Number of control points minus 1
297
+
298
+ if hasattr(self, "debug") and self.debug:
299
+ print("\nCONTROL POINTS CALCULATION:")
300
+ print(f" n = {n} (number of points minus 1)")
301
+ print(f" m = {m} (number of control points minus 1)")
302
+
303
+ # Initialize control points
304
+ control_points = np.zeros((num_control_points, points.shape[1]))
305
+
306
+ # First and last control points are fixed to first and last data points
307
+ # as per condition 1 in Section 8.5
308
+ control_points[0] = points[0]
309
+ control_points[m] = points[n]
310
+
311
+ if hasattr(self, "debug") and self.debug:
312
+ print(" Fixed control points:")
313
+ print(f" P_0 = {points[0]}")
314
+ print(f" P_{m} = {points[n]}")
315
+
316
+ # Handle case where we only have 2 control points
317
+ if m <= 1:
318
+ if hasattr(self, "debug") and self.debug:
319
+ print(" Only two control points needed, returning interpolated curve.")
320
+ return control_points
321
+
322
+ # Create a temporary B-spline for basis function calculation
323
+ temp_control_points = np.zeros((num_control_points, points.shape[1]))
324
+ temp_bspline = BSpline(degree, knots, temp_control_points)
325
+
326
+ # Initialize matrices for internal points
327
+ # B is (n-1) x (m-1) matrix as in equation (8.21)
328
+ b_matrix = np.zeros((n - 1, m - 1))
329
+ # R is (n-1) x d matrix where d is the dimension of points
330
+ r_matrix = np.zeros((n - 1, points.shape[1]))
331
+
332
+ if hasattr(self, "debug") and self.debug:
333
+ print(f" B matrix shape: {b_matrix.shape}")
334
+ print(f" R matrix shape: {r_matrix.shape}")
335
+
336
+ # For each internal parameter value (k=1 to n-1)
337
+ for k in range(1, n):
338
+ u = u_bar[k]
339
+
340
+ if hasattr(self, "debug") and self.debug:
341
+ print(f"\n Processing point {k} at parameter u = {u:.6f}")
342
+
343
+ # Calculate all basis function values at parameter u
344
+ # This evaluates all basis functions B_j^p(u_k) for j=0,...,m
345
+ all_basis = np.zeros(m + 1)
346
+
347
+ # Find the knot span that contains u
348
+ span = temp_bspline.find_knot_span(u)
349
+
350
+ if hasattr(self, "debug") and self.debug:
351
+ print(f" Knot span for u = {u:.6f} is {span}")
352
+
353
+ # Get non-zero basis functions at this parameter
354
+ basis_values = temp_bspline.basis_functions(u, span)
355
+
356
+ if hasattr(self, "debug") and self.debug:
357
+ print(f" Non-zero basis values: {basis_values}")
358
+
359
+ # Map the basis functions to the correct indices in all_basis
360
+ # Only p+1 basis functions are non-zero at any parameter value
361
+ for j in range(degree + 1):
362
+ idx = span - degree + j
363
+ if 0 <= idx <= m:
364
+ all_basis[idx] = basis_values[j]
365
+
366
+ if hasattr(self, "debug") and self.debug:
367
+ print(" All basis function values:")
368
+ for j, val in enumerate(all_basis):
369
+ print(f" B_{j}^{degree}({u:.6f}) = {val:.6f}")
370
+
371
+ # Fill the k-th row of matrix B with values for internal control points (j=1 to m-1)
372
+ # Exactly as in Equation (8.21)
373
+ for j in range(1, m):
374
+ b_matrix[k - 1, j - 1] = all_basis[j]
375
+
376
+ # Calculate the k-th row of matrix R
377
+ # R_k = q_k - B_0^p(u_k)q_0 - B_m^p(u_k)q_m
378
+ # This follows directly from the equation after (8.20) in the document
379
+ r_matrix[k - 1] = points[k] - all_basis[0] * points[0] - all_basis[m] * points[n]
380
+
381
+ if hasattr(self, "debug") and self.debug:
382
+ print(
383
+ f" Row {k - 1} of R matrix = q_{k} - B_0^{degree}({u:.6f})*q_0 - "
384
+ f"B_{m}^{degree}({u:.6f})*q_{n}"
385
+ )
386
+ print(
387
+ f" = {points[k]} - {all_basis[0]:.6f}*{points[0]} - "
388
+ f"{all_basis[m]:.6f}*{points[n]}"
389
+ )
390
+ print(f" = {r_matrix[k - 1]}")
391
+
392
+ if hasattr(self, "debug") and self.debug:
393
+ print("\n B matrix:")
394
+ for i in range(b_matrix.shape[0]):
395
+ row = " ["
396
+ for j in range(b_matrix.shape[1]):
397
+ row += f"{b_matrix[i, j]:.6f}, "
398
+ row = row[:-2] + "]"
399
+ print(row)
400
+
401
+ print("\n R matrix (first dimension):")
402
+ for i in range(r_matrix.shape[0]):
403
+ row = " ["
404
+ # Define max columns to display
405
+ max_cols = 3
406
+ for j in range(min(max_cols, r_matrix.shape[1])):
407
+ row += f"{r_matrix[i, j]:.6f}, "
408
+ if r_matrix.shape[1] > max_cols:
409
+ row += "..."
410
+ else:
411
+ row = row[:-2]
412
+ row += "]"
413
+ print(row)
414
+
415
+ # Apply weights to minimize the weighted least squares functional in equation (8.19)
416
+ w_matrix = np.diag(weights)
417
+
418
+ if hasattr(self, "debug") and self.debug:
419
+ print("\n Weights:")
420
+ print(f" {weights}")
421
+
422
+ # Compute weighted pseudo-inverse solution according to equation (8.25)
423
+ # B† = (B^T W B)^(-1) B^T W
424
+ btw = np.dot(b_matrix.T, w_matrix)
425
+ btwb = np.dot(btw, b_matrix)
426
+ btwr = np.dot(btw, r_matrix)
427
+
428
+ if hasattr(self, "debug") and self.debug:
429
+ print("\n Calculating pseudo-inverse solution:")
430
+ print(f" B^T W shape: {btw.shape}")
431
+ print(f" B^T W B shape: {btwb.shape}")
432
+ print(f" B^T W R shape: {btwr.shape}")
433
+
434
+ # Solve for internal control points: P = B† R
435
+ try:
436
+ # Solve the normal equations for the least squares solution
437
+ internal_control_points = np.linalg.solve(btwb, btwr)
438
+
439
+ if hasattr(self, "debug") and self.debug:
440
+ print(" Used np.linalg.solve (direct solution)")
441
+ except np.linalg.LinAlgError:
442
+ # If matrix is singular or poorly conditioned, use pseudo-inverse
443
+ # This provides the least squares solution that minimizes the norm of P
444
+ internal_control_points = np.dot(np.linalg.pinv(btwb), btwr)
445
+
446
+ if hasattr(self, "debug") and self.debug:
447
+ print(" Used np.linalg.pinv (matrix was singular or poorly conditioned)")
448
+
449
+ # Assign internal control points (p_1 to p_(m-1))
450
+ control_points[1:m] = internal_control_points
451
+
452
+ if hasattr(self, "debug") and self.debug:
453
+ print("\n Calculated internal control points:")
454
+ for i in range(1, m):
455
+ print(f" P_{i} = {control_points[i]}")
456
+
457
+ return control_points
458
+
459
+ def calculate_approximation_error(
460
+ self, points: np.ndarray | None = None, u_bar: np.ndarray | None = None
461
+ ) -> float:
462
+ """Calculate the approximation error as the sum of squared distances.
463
+
464
+ Computes sum of squared distances between the points and the corresponding
465
+ points on the B-spline.
466
+
467
+ Parameters
468
+ ----------
469
+ points : np.ndarray or None, default=None
470
+ The points to compare with the B-spline. If None, the original points
471
+ used for approximation are used.
472
+ u_bar : np.ndarray or None, default=None
473
+ Parameter values for the points. If None, the original parameters are
474
+ used for original points, or computed for new points.
475
+
476
+ Returns
477
+ -------
478
+ float
479
+ The sum of squared distances.
480
+ """
481
+ # Use original points and parameters if not provided
482
+ if points is None:
483
+ points = self.original_points
484
+ u_bar = self.original_parameters
485
+
486
+ # Calculate the sum of squared distances
487
+ sum_squared_dist = 0.0
488
+ for i, point in enumerate(points):
489
+ # Evaluate the B-spline at the parameter value
490
+ spline_point = self.evaluate(u_bar[i]) # type: ignore
491
+
492
+ # Calculate squared distance
493
+ squared_dist = np.sum((point - spline_point) ** 2)
494
+ sum_squared_dist += squared_dist
495
+
496
+ return sum_squared_dist
497
+
498
+ def refine(
499
+ self, max_error: float = 0.1, max_control_points: int = 100
500
+ ) -> "ApproximationBSpline":
501
+ """Refine the approximation by adding more control points.
502
+
503
+ Adds control points until the maximum error is below a threshold or the
504
+ maximum number of control points is reached.
505
+
506
+ Parameters
507
+ ----------
508
+ max_error : float, default=0.1
509
+ Maximum acceptable error.
510
+ max_control_points : int, default=100
511
+ Maximum number of control points.
512
+
513
+ Returns
514
+ -------
515
+ ApproximationBSpline
516
+ A refined approximation B-spline.
517
+ """
518
+ # Start with the current number of control points
519
+ num_control_points = len(self.control_points)
520
+
521
+ # Calculate initial error
522
+ error = self.calculate_approximation_error()
523
+
524
+ while error > max_error and num_control_points < max_control_points:
525
+ # Increase the number of control points
526
+ num_control_points += 1
527
+
528
+ # Create a new approximation B-spline with more control points
529
+ new_spline = ApproximationBSpline(
530
+ self.original_points, num_control_points, degree=self.degree
531
+ )
532
+
533
+ # Calculate the new error
534
+ error = new_spline.calculate_approximation_error()
535
+
536
+ # If error is below threshold, return the new spline
537
+ if error <= max_error:
538
+ return new_spline
539
+
540
+ # If we've reached max_control_points but error is still above threshold,
541
+ # return the best approximation we have
542
+ return ApproximationBSpline(
543
+ self.original_points, min(num_control_points, max_control_points), degree=self.degree
544
+ )