Source code for ktch.harmonic._elliptic_fourier_analysis

"""Elliptic Fourier Analysis"""

# Copyright 2020 Koji Noshita
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import numpy as np
import numpy.typing as npt
import pandas as pd
from sklearn.base import (
    BaseEstimator,
    ClassNamePrefixFeaturesOutMixin,
    TransformerMixin,
)
from sklearn.utils.parallel import Parallel, delayed

from ._registration import moment_register

# Tolerance for detecting degenerate (near-zero) geometric quantities
# (arc length, semi-axes, phase-angle denominator).
_DEGENERACY_TOL = 1e-15

# Floor value for near-zero arc-length segments when
# duplicated_points="infinitesimal".
_INFINITESIMAL_DT = 1e-10

# Tolerance for gimbal-lock detection in ZXZ Euler angle extraction.
_GIMBAL_TOL = 1e-10


[docs] class EllipticFourierAnalysis( ClassNamePrefixFeaturesOutMixin, TransformerMixin, BaseEstimator ): r""" Elliptic Fourier Analysis (EFA) Parameters ---------- n_harmonics: int, default=20 Number of harmonics n_dim: int, default=2 Dimension of the codomain (number of components on the curve). Any positive integer is supported for the raw expansion (``registration=None``). Arc-length parameterization is computed automatically only for ``n_dim`` in ``(2, 3)`` (spatial shape coordinates). For ``n_dim=1`` or ``n_dim>3`` the codomain is treated as non-shape data, so the parameter ``t`` is required and must be supplied explicitly (e.g. derived from the corresponding shape); it is not inferred from the codomain values. registration : {"auto", None, "first_order", "moment"}, default="auto" Shape-registration method. ``"auto"`` (default) registers 2D/3D shape data with ``"first_order"`` and leaves other dimensions unregistered (``None``). ``None`` returns raw coefficients. ``"first_order"`` uses the 1st-harmonic ellipse; ``"moment"`` uses the inertia tensor of all coefficients. Registration applies to 2D/3D shape data only. `registration` must be ``None`` for``n_dim`` not in ``(2, 3)``. scale : bool, default=True Whether registration removes size. ``False`` keeps size. Only used when ``registration != None``. (``scale=False`` is currently implemented for ``"moment"`` only.) scale_method : str or None, default=None Size measure when ``scale=True``; one of ``"semi_major_axis"``, ``"ellipse_area"``, ``"centroid_size"``, or ``None``. ``None`` resolves to the method's default (``"first_order"``: 2D ``"semi_major_axis"``, 3D ``"ellipse_area"``; ``"moment"``: ``"centroid_size"``). ``"semi_major_axis"`` and ``"ellipse_area"`` require ``registration="first_order"``; ``"centroid_size"`` requires ``registration="moment"``. align_parameter : bool, default=True Whether to align the parameter domain (phase) in ``"first_order"``. Only ``True`` is implemented (it is always applied); ``False`` raises ``NotImplementedError``. reflect : bool, default=False Whether to also remove reflection (chirality). Honored by ``"moment"``. For ``"first_order"`` only ``reflect=False`` (orientation preserved) is implemented; ``reflect=True`` raises ``NotImplementedError``. return_transform : bool, default=False Append the registration parameters as extra output columns. Currently only for ``"first_order"`` with ``n_dim`` in ``(2, 3)``; other methods raise ``NotImplementedError``. - 2D: Appends ``[psi, scale]`` to the end of the coefficient vector. - 3D: Appends ``[alpha, beta, gamma, phi, scale]`` to the end. n_jobs: int, default=None The number of jobs to run in parallel. None means 1 unless in a joblib.parallel_backend context. -1 means using all processors. verbose: int, default=0 The verbosity level. norm : bool, default=True Deprecated alias. ``True`` maps to ``registration="first_order"``, ``False`` to ``registration=None``. Use ``registration`` instead. norm_method : {None, "area", "semi_major_axis"}, default=None Deprecated alias for ``scale_method`` (``"area"`` -> ``"ellipse_area"``). When ``None`` the dimension-appropriate convention is used: ``"semi_major_axis"`` for 2D and ``"area"`` for 3D. - ``"area"``: Scale by ``sqrt(pi * a1 * b1)`` where ``a1`` and ``b1`` are the semi-major and semi-minor axis lengths of the 1st harmonic ellipse (Godefroy et al. 2012). - ``"semi_major_axis"``: Scale by the semi-major axis length ``a1`` of the 1st harmonic ellipse (Kuhl & Giardina 1982). return_orientation_scale : bool, default=False Deprecated alias for ``return_transform`` producing identical output (requires ``registration != None``). Notes ----- EFA is widely applied for outline shape analysis in two-dimensional space [Kuhl_Giardina_1982]_. .. math:: \begin{align} x(l) &= \frac{a_0}{2} + \sum_{i=1}^{n} \left[ a_i \cos\left(\frac{2\pi i t}{T}\right) + b_i \sin\left(\frac{2\pi i t}{T}\right) \right]\\ y(l) &= \frac{c_0}{2} + \sum_{i=1}^{n} \left[ c_i \cos\left(\frac{2\pi i t}{T}\right) + d_i \sin\left(\frac{2\pi i t}{T}\right) \right]\\ \end{align} EFA is also applied for a closed curve in the three-dimensional space (e.g., [Lestrel_1997]_, [Lestrel_et_al_1997]_, and [Godefroy_et_al_2012]_). For 3D data (``n_dim=3``), ``"first_order"`` registration follows Godefroy et al. (2012) §3.1: rescaling by the 1st harmonic ellipse area, reorientation using ZXZ Euler angles, phase shift, and direction correction. When ``return_transform=True`` with registered data, extra values are appended to the output: - 2D: ``[psi, scale]`` where ``psi`` is the orientation angle and ``scale`` is the normalization factor. - 3D: ``[alpha, beta, gamma, phi, scale]`` where ``(alpha, beta, gamma)`` are ZXZ Euler angles (in radians) of the 1st harmonic ellipse orientation, ``phi`` is the phase angle, and ``scale`` is the normalization factor. The ``scale`` value depends on ``scale_method``: ``sqrt(pi * a1 * b1)`` for ``"ellipse_area"``, or ``a1`` for ``"semi_major_axis"``. The legacy parameters ``norm`` / ``norm_method`` / ``return_orientation_scale`` remain as deprecated aliases of ``registration`` / ``scale_method`` / ``return_transform``. References ---------- .. [Kuhl_Giardina_1982] Kuhl, F.P., Giardina, C.R. (1982) Elliptic Fourier features of a closed contour. Comput. Graph. Image Process. 18: 236–258. https://doi.org/10.1016/0146-664X(82)90034-X .. [Lestrel_1997] Lestrel, P.E., 1997. Introduction and overview of Fourier descriptors, in: Fourier Descriptors and Their Applications in Biology. Cambridge University Press, pp. 22–44. https://doi.org/10.1017/cbo9780511529870.003 .. [Lestrel_et_al_1997] Lestrel, P.E., Read, D.W., Wolfe, C., 1997. Size and shape of the rabbit orbit: 3-D Fourier descriptors, in: Lestrel, P.E. (Ed.), Fourier Descriptors and Their Applications in Biology. Cambridge University Press, pp. 359–378. https://doi.org/10.1017/cbo9780511529870.017 .. [Godefroy_et_al_2012] Godefroy, J.E., Bornert, F., Gros, C.I., Constantinesco, A., 2012. Elliptical Fourier descriptors for contours in three dimensions: A new tool for morphometrical analysis in biology. C. R. Biol. 335, 205–213. https://doi.org/10.1016/j.crvi.2011.12.004 """ _VALID_NORM_METHODS = {None, "area", "semi_major_axis"} _VALID_REGISTRATIONS = { None, "first_order", "moment", "landmark", "rotational_match", } _VALID_SCALE_METHODS = { None, "semi_major_axis", "ellipse_area", "centroid_size", } # Size measures permitted per registration method. _SCALE_METHODS_BY_REGISTRATION = { "first_order": {None, "semi_major_axis", "ellipse_area"}, "moment": {None, "centroid_size"}, } # Deprecated norm_method -> new scale_method names. _LEGACY_SCALE_METHOD_MAP = { "area": "ellipse_area", "semi_major_axis": "semi_major_axis", } # New scale_method -> legacy values understood by _normalize_2d/_normalize_3d. _SCALE_METHOD_TO_LEGACY = { "ellipse_area": "area", "semi_major_axis": "semi_major_axis", } def __init__( self, n_harmonics: int = 20, n_dim: int = 2, registration: str | None = "auto", scale: bool = True, scale_method: str | None = None, align_parameter: bool = True, reflect: bool = False, return_transform: bool = False, n_jobs: int | None = None, verbose: int = 0, norm: bool = True, norm_method: str | None = None, return_orientation_scale: bool = False, ): self.n_harmonics = n_harmonics self.n_dim = n_dim self.registration = registration self.scale = scale self.scale_method = scale_method self.align_parameter = align_parameter self.reflect = reflect self.return_transform = return_transform self.n_jobs = n_jobs self.verbose = verbose # Deprecated aliases (kept for backward compatibility). self.norm = norm self.norm_method = norm_method self.return_orientation_scale = return_orientation_scale def _resolve_registration(self): """Resolve effective registration settings from new + legacy params. Legacy params (``norm`` / ``norm_method`` / ``return_orientation_scale``) map onto the new API. Setting a legacy param to a non-default value together with its new counterpart raises ``ValueError``. Returns ------- tuple ``(method, scale, scale_method, return_extras)``. """ # registration <- norm (norm=False is the only active legacy signal; # norm=True equals the default and does not override). if self.norm is False: if self.registration != "auto": raise ValueError( "Cannot set both `norm` (deprecated) and `registration`. " "Use `registration` only." ) method = None scale = True else: method = self.registration scale = self.scale # Resolve "auto": register 2D/3D shape data, leave others unregistered. if method == "auto": method = "first_order" if self.n_dim in (2, 3) else None # scale_method <- norm_method if self.norm_method is not None: if self.norm_method not in self._VALID_NORM_METHODS: raise ValueError( f"norm_method must be None, 'area', or 'semi_major_axis', " f"got '{self.norm_method}'" ) if self.scale_method is not None: raise ValueError( "Cannot set both `norm_method` (deprecated) and " "`scale_method`. Use `scale_method` only." ) scale_method = self._LEGACY_SCALE_METHOD_MAP[self.norm_method] else: scale_method = self.scale_method # return columns <- return_transform / return_orientation_scale. # Both flags request the same method-specific appended layout # (2D `[psi, scale]`; 3D `[alpha, beta, gamma, phi, scale]`); # `return_orientation_scale` is a deprecated alias of `return_transform` # producing identical output. The registration transform is 2D/3D only, # so there is no separate n-D / matrix "general" layout. if self.return_orientation_scale and self.return_transform: raise ValueError( "Cannot set both `return_orientation_scale` (deprecated) " "and `return_transform`. Use `return_transform` only." ) return_extras = self.return_transform or self.return_orientation_scale return method, scale, scale_method, return_extras def _validate_registration(self, method, scale, scale_method, return_extras): """Validate effective registration settings; raise on bad combos.""" if method not in self._VALID_REGISTRATIONS: valid = sorted(str(m) for m in self._VALID_REGISTRATIONS) raise ValueError(f"registration must be one of {valid}, got '{method}'") if method in ("landmark", "rotational_match"): raise NotImplementedError( f"registration='{method}' is reserved and not implemented yet." ) if scale_method not in self._VALID_SCALE_METHODS: raise ValueError( f"scale_method must be one of " f"{sorted(str(m) for m in self._VALID_SCALE_METHODS)}, " f"got '{scale_method}'" ) if method is None: if return_extras: raise ValueError( "return_transform/return_orientation_scale requires " "registration != None." ) return if self.n_dim not in (2, 3): raise ValueError( f"registration='{method}' applies to 2D/3D shape data only; " f"got n_dim={self.n_dim}. Use registration=None for non-shape " "data (normalization of n-D fields belongs to a separate " "interface)." ) allowed = self._SCALE_METHODS_BY_REGISTRATION[method] if scale_method not in allowed: raise ValueError( f"scale_method='{scale_method}' is not valid for " f"registration='{method}'; valid options: " f"{sorted(str(m) for m in allowed)}." ) if return_extras and not (method == "first_order" and self.n_dim in (2, 3)): raise NotImplementedError( "return_transform/return_orientation_scale is currently " "implemented only for registration='first_order' with " "n_dim in (2, 3)." ) if method == "first_order" and not self.align_parameter: raise NotImplementedError( "align_parameter=False is not yet implemented; 'first_order' " "always aligns the parameter domain (phase). Use " "align_parameter=True." ) if method == "first_order" and self.reflect: raise NotImplementedError( "reflect=True is not yet implemented for " "registration='first_order' in EFA; orientation is preserved. " "Use registration='moment' for reflection removal, or " "reflect=False." )
[docs] def fit(self, X, y=None): """Fit the model (no-op for stateless transformer). Parameters ---------- X : ignored y : ignored Returns ------- self """ return self
[docs] def fit_transform(self, X, y=None, t=None): """Fit and transform in a single step. Overridden to support metadata routing of ``t``. Parameters ---------- X : list of array-like of shape (n_coords_i, n_dim) Coordinate values of n_samples. y : ignored t : list of array-like, optional Per-sample parameterization. Passed to ``transform``. Returns ------- X_transformed : ndarray of shape (n_samples, n_features_out) """ return self.fit(X, y).transform(X, t=t)
[docs] def transform( self, X: list[npt.ArrayLike] | npt.ArrayLike, t: npt.ArrayLike = None, ) -> npt.ArrayLike: """Elliptic Fourier Analysis. Parameters ---------- X : {list of array-like, array-like} of shape (n_samples, n_coords, n_dim) Coordinate values of n_samples. The i-th array-like of shape (n_coords_i, n_dim) represents coordinate values of the i-th sample. t : list of array-like of shape (n_coords_i,), optional Parameters indicating the position on the outline of n_samples. The i-th element corresponds to each coordinate value in the i-th element of X. If ``None``, arc-length parameterization is computed automatically. Returns ------- X_transformed : ndarray of shape (n_samples, n_features_out) Elliptic Fourier coefficients. For a general codomain dimension ``n_dim=D`` with ``norm=False`` the layout is, per axis, ``[cos_0..cos_n, sin_0..sin_n]`` concatenated over the ``D`` axes, giving length ``2 * D * (n_harmonics + 1)``. The 2D/3D cases below are special cases of this layout (with letter names ``a, b, c, d, ...``). - 2D (return_orientation_scale=False): ``[a_0..a_n, b_0..b_n, c_0..c_n, d_0..d_n]`` length = ``4 * (n_harmonics + 1)``. - 2D (return_orientation_scale=True): Same as above with ``[psi, scale]`` appended (length +2). - 3D (return_orientation_scale=False): ``[a_0..a_n, b_0..b_n, c_0..c_n, d_0..d_n, e_0..e_n, f_0..f_n]`` length = ``6 * (n_harmonics + 1)``. - 3D (return_orientation_scale=True): Same as above with ``[alpha, beta, gamma, phi, scale]`` appended (length +5). """ n_dim = self.n_dim if n_dim < 1: raise ValueError(f"n_dim must be a positive integer, got {n_dim}") # Resolve + validate registration early. method, scale, scale_method, return_extras = self._resolve_registration() self._validate_registration(method, scale, scale_method, return_extras) if t is None: t_ = [None] * len(X) else: t_ = t if len(t_) != len(X): raise ValueError(f"t ({len(t_)}) must have the same length as X ({len(X)})") if isinstance(X, pd.DataFrame): X_ = [ row.dropna().to_numpy().reshape(n_dim, -1).T for idx, row in X.iterrows() ] else: X_ = X X_transformed = np.stack( Parallel(n_jobs=self.n_jobs, verbose=self.verbose)( delayed(self._transform_single)(X_[i], t_[i]) for i in range(len(X_)) ) ) return X_transformed
[docs] def inverse_transform(self, X_transformed, t_num=100, as_frame=False): """Inverse analysis of elliptic Fourier analysis. Parameters ---------- X_transformed : array-like of shape (n_samples, n_features) Elliptic Fourier coefficients. Accepted lengths per sample: - 2D: ``4*(n_harmonics+1)`` or ``4*(n_harmonics+1)+2`` (psi, scale). - 3D: ``6*(n_harmonics+1)`` or ``6*(n_harmonics+1)+5`` (alpha, beta, gamma, phi, scale). Orientation/scale columns, if present, are ignored for reconstruction. t_num : int, default = 100 Number of coordinate values. as_frame : bool, default = False If True, return pd.DataFrame. Returns ------- X_coords : array-like of shape (n_samples, t_num, n_dim) or pd.DataFrame Coordinate values reconstructed from the elliptic Fourier coefficients. """ X_list = [] sp_num = X_transformed.shape[0] col_names = _coord_names(self.n_dim) for i in range(sp_num): if as_frame: coef = X_transformed.loc[i] X = self._inverse_transform_single(coef, t_num=t_num) df_X = pd.DataFrame(X, columns=col_names) df_X["coord_id"] = list(range(len(X))) df_X["specimen_id"] = i X_list.append(df_X) else: coef = X_transformed[i] X = self._inverse_transform_single(coef, t_num=t_num) X_list.append(X) if as_frame: X_coords = pd.concat(X_list) X_coords = X_coords.set_index(["specimen_id", "coord_id"]) else: X_coords = np.stack(X_list) return X_coords
########################################################### # # 2D # ########################################################### def _transform_single( self, X: np.ndarray, t: np.ndarray | None = None, duplicated_points: str = "infinitesimal", ): """Fit the model with a single outline. Computes the raw Fourier coefficients for an ``n_dim``-valued closed curve and, when ``self.norm`` is set, applies the dimension-specific normalization. Parameters ---------- X : ndarray of shape (n_coords, n_dim) Coordinate values of the outline. t : ndarray of shape (n_coords,), optional A parameter indicating the position on the outline. For ``n_dim`` in ``(2, 3)`` (spatial shape coordinates), ``None`` triggers automatic arc-length parameterization. For ``n_dim=1`` or ``n_dim>3`` the codomain is treated as non-shape data, so ``t`` is required and is not inferred from the codomain values. duplicated_points : str, default="infinitesimal" Strategy for zero-length segments (``"infinitesimal"`` or ``"deletion"``). Returns ------- X_transformed : ndarray Flat Fourier coefficient vector. Per axis the layout is ``[cos_0..cos_n, sin_0..sin_n]``, concatenated over the axes. """ n_harmonics = self.n_harmonics if t is None and self.n_dim not in (2, 3): raise ValueError( "Automatic arc-length parameterization is only available for " f"n_dim in (2, 3); got n_dim={self.n_dim}. For n_dim=1 or " "n_dim>3 the codomain is treated as non-shape data, so `t` " "must be supplied explicitly (e.g. a parameterization derived " "from the corresponding shape)." ) X_arr, diffs, dt = _preprocess_outline(X, t, duplicated_points) n_dim = X_arr.shape[1] if n_dim != self.n_dim: raise ValueError( f"Each sample must have n_dim={self.n_dim} columns; got {n_dim}." ) # Fourier series expansion T = np.sum(dt) # DC offset per axis (weighted mean over the contour) offsets = 2.0 * (dt @ X_arr[1:]) / T cos_rows = [ np.append(offsets[d], _cse(diffs[:, d], dt, n_harmonics)) for d in range(n_dim) ] sin_rows = [ np.append(0.0, _sse(diffs[:, d], dt, n_harmonics)) for d in range(n_dim) ] method, scale, scale_method, return_extras = self._resolve_registration() self._validate_registration(method, scale, scale_method, return_extras) def _raw_rows(): out = [] for d in range(n_dim): out.append(cos_rows[d]) out.append(sin_rows[d]) return out if method is None: return np.hstack(_raw_rows()) if method == "moment": if return_extras: raise NotImplementedError( "return_transform is not yet implemented for registration='moment'." ) raw_flat = np.hstack(_raw_rows()) return moment_register(raw_flat, n_dim, scale=scale, reflect=self.reflect) # method == "first_order" if not scale: raise NotImplementedError( "scale=False (form space) is not yet implemented for " "registration='first_order'." ) # New scale_method names map onto the legacy values understood by # _normalize_2d/_normalize_3d ("ellipse_area" -> "area"). legacy_sm = ( None if scale_method is None else self._SCALE_METHOD_TO_LEGACY[scale_method] ) if n_dim == 2: an, bn, cn, dn, psi, s = self._normalize_2d( cos_rows[0], sin_rows[0], cos_rows[1], sin_rows[1], scale_method=legacy_sm, ) rows = [an, bn, cn, dn] extras = [psi, s] elif n_dim == 3: ( an, bn, cn, dn, en, fn, alpha, beta, gamma, phi, s, ) = self._normalize_3d( cos_rows[0], sin_rows[0], cos_rows[1], sin_rows[1], cos_rows[2], sin_rows[2], scale_method=legacy_sm, ) rows = [an, bn, cn, dn, en, fn] extras = [alpha, beta, gamma, phi, s] else: # unreachable: _validate_registration rejects non-2D/3D first. raise ValueError( f"registration applies to 2D/3D shape data only; got n_dim={n_dim}." ) if return_extras: return np.hstack(rows + extras) return np.hstack(rows) def _transform_single_2d( self, X: np.ndarray, t: np.ndarray | None = None, duplicated_points: str = "infinitesimal", ): """Backward-compatible 2D entry point. Thin wrapper over :meth:`_transform_single`; retained for callers that target the 2D path explicitly. """ return self._transform_single(X, t=t, duplicated_points=duplicated_points) def _normalize_2d(self, an, bn, cn, dn, keep_start_point=False, scale_method=None): """Normalize Fourier coefficients. ``scale_method`` accepts the legacy values ``"area"`` / ``"semi_major_axis"``; ``None`` falls back to ``self.norm_method``. Todo: - [ ] Procrustes alignment -> in coordinate values? Returns ------- An, Bn, Cn, Dn : np.ndarray Normalized coefficient arrays (offset + harmonics). psi : float Orientation (phase) of the 1st ellipse in radians. scale : float Scaling factor. semi-major axis length, or area of the 1st ellipse. """ a1 = an[1] b1 = bn[1] c1 = cn[1] d1 = dn[1] theta = 0.5 * np.arctan2(2 * (a1 * b1 + c1 * d1), a1**2 + c1**2 - b1**2 - d1**2) [[a_s, b_s], [c_s, d_s]] = np.array([[a1, b1], [c1, d1]]).dot( rotation_matrix_2d(theta) ) s1 = a_s**2 + c_s**2 s2 = b_s**2 + d_s**2 if s1 < s2: if theta < 0: theta = theta + np.pi / 2 else: theta = theta - np.pi / 2 cos_th = np.cos(theta) sin_th = np.sin(theta) a_s = a1 * cos_th + b1 * sin_th c_s = c1 * cos_th + d1 * sin_th semi_major = np.sqrt(a_s**2 + c_s**2) norm_method = scale_method if scale_method is not None else self.norm_method if norm_method is None: norm_method = "semi_major_axis" if norm_method == "semi_major_axis": scale = semi_major else: # "area" b_s = -a1 * sin_th + b1 * cos_th d_s = -c1 * sin_th + d1 * cos_th semi_minor = np.sqrt(b_s**2 + d_s**2) scale = np.sqrt(np.pi * semi_major * semi_minor) psi = np.arctan2(c_s, a_s) if keep_start_point: theta = 0 coef_norm_list = [] r_psi = rotation_matrix_2d(-psi) for n in range(1, len(an)): r_ntheta = rotation_matrix_2d(n * theta) coef_orig = np.array([[an[n], bn[n]], [cn[n], dn[n]]]) coef_norm_tmp = (1 / scale) * np.dot(np.dot(r_psi, coef_orig), r_ntheta) coef_norm_list.append(coef_norm_tmp.reshape(-1)) coef_norm = np.stack(coef_norm_list) An = np.append(an[0], coef_norm[:, 0]) Bn = np.append(bn[0], coef_norm[:, 1]) Cn = np.append(cn[0], coef_norm[:, 2]) Dn = np.append(dn[0], coef_norm[:, 3]) return An, Bn, Cn, Dn, psi, scale def _inverse_transform_single(self, X_transformed, t_num=100): coef_array = np.asarray(X_transformed, dtype=float) n_axes = 2 * self.n_dim n_extras = {2: 2, 3: 5}.get(self.n_dim, 0) expected_base = n_axes * (self.n_harmonics + 1) if coef_array.shape[0] == expected_base: coef_core = coef_array elif n_extras and coef_array.shape[0] == expected_base + n_extras: coef_core = coef_array[:expected_base] else: allowed = ( f"{expected_base} or {expected_base + n_extras}" if n_extras else f"{expected_base}" ) raise ValueError( f"Expected {allowed} coefficients, got {coef_array.shape[0]}." ) # Reshape to (n_axes, n_harmonics+1). # Axes are ordered [cos0, sin0, cos1, sin1, ...] per coordinate. axes = coef_core.reshape([n_axes, -1]) # Offsets sit at index 0 of the cos rows. Registered coefficients are # translation-free (centered), so the stored offset is dropped. offsets = axes[::2, 0].copy() method, _, _, _ = self._resolve_registration() if method is not None: offsets[:] = 0.0 # (n_dim, n_harmonics) cos_coefs = axes[::2, 1:] sin_coefs = axes[1::2, 1:] n_max = cos_coefs.shape[1] theta = np.linspace(2 * np.pi / t_num, 2 * np.pi, t_num) ns = np.arange(1, n_max + 1) # (n_max, t_num) cos_basis = np.cos(np.outer(ns, theta)) sin_basis = np.sin(np.outer(ns, theta)) # Reconstruct coordinates: (n_dim, t_num) coords = offsets[:, None] / 2 + cos_coefs @ cos_basis + sin_coefs @ sin_basis return coords.T ########################################################### # # 3D # ########################################################### def _transform_single_3d( self, X: np.ndarray, t: np.ndarray | None = None, duplicated_points: str = "infinitesimal", ): """Backward-compatible 3D entry point. Thin wrapper over :meth:`_transform_single`; retained for callers that target the 3D path explicitly. """ return self._transform_single(X, t=t, duplicated_points=duplicated_points) def _normalize_3d(self, an, bn, cn, dn, en, fn, scale_method=None): """Normalize 3D EFA coefficients. ``scale_method`` accepts the legacy values ``"area"`` / ``"semi_major_axis"``; ``None`` falls back to ``self.norm_method``. Applies the 4-step normalization algorithm: 1. Rescaling by a scale factor determined by ``scale_method`` (``None`` resolves to ``"area"`` for 3D): - ``"area"``: ``scale = sqrt(pi * a1 * b1)`` - ``"semi_major_axis"``: ``scale = a1`` 2. Reorientation using the 1st harmonic's Euler angles 3. Phase shift using the 1st harmonic's phase angle 4. Direction correction (sign of y-sine component) Parameters ---------- an, bn, cn, dn, en, fn : np.ndarray of shape (n_harmonics+1,) Raw Fourier coefficient arrays. Index 0 is the offset. Returns ------- An, Bn, Cn, Dn, En, Fn : np.ndarray of shape (n_harmonics+1,) Normalized coefficient arrays. alpha, beta, gamma : float ZXZ Euler angles of the 1st harmonic ellipse. phi : float Phase angle of the 1st harmonic ellipse. scale : float Scaling factor. ``sqrt(pi * a1 * b1)`` when ``norm_method="area"``, or ``a1`` when ``norm_method="semi_major_axis"``. Notes ----- When ``return_orientation_scale=True`` in 3D, these five values are appended to the transform output in the order: ``[alpha, beta, gamma, phi, scale]``. """ # Extract geometric parameters of the 1st harmonic phi1, a1, b1, alpha1, beta1, gamma1 = _compute_ellipse_geometry_3d( an[1], bn[1], cn[1], dn[1], en[1], fn[1] ) # Handle degenerate 1st harmonic if a1 < _DEGENERACY_TOL: raise ValueError( "Degenerate 1st harmonic: the ellipse has near-zero semi-axes. " "Cannot normalize 3D EFA coefficients." ) # 1. Rescaling norm_method = scale_method if scale_method is not None else self.norm_method if norm_method is None: norm_method = "area" if norm_method == "semi_major_axis": scale = a1 else: # Area-based (Godefroy et al. 2012) area1 = np.pi * a1 * b1 scale = np.sqrt(area1) # 2. Reorientation matrix (Omega1_inv = Omega1^T) Omega1 = rotation_matrix_3d_euler_zxz(alpha1, beta1, gamma1) Omega1_inv = Omega1.T n_harmonics = len(an) - 1 An = np.empty_like(an) Bn = np.empty_like(bn) Cn = np.empty_like(cn) Dn = np.empty_like(dn) En = np.empty_like(en) Fn = np.empty_like(fn) An[0] = an[0] Bn[0] = bn[0] Cn[0] = cn[0] Dn[0] = dn[0] En[0] = en[0] Fn[0] = fn[0] for k in range(1, n_harmonics + 1): # Build 3x2 coefficient matrix # C_k = [[an_k, bn_k], [cn_k, dn_k], [en_k, fn_k]] C_k = np.array( [ [an[k], bn[k]], [cn[k], dn[k]], [en[k], fn[k]], ] ) # 3. Phase rotation uses k*phi1 for harmonic k # Removing phase phi1 means substituting t -> t + phi1: # new_xc = xc*cos(k*phi1) + xs*sin(k*phi1) # new_xs = -xc*sin(k*phi1) + xs*cos(k*phi1) # In matrix form: C_k @ R(-k*phi1) where R is the standard rotation matrix angle_k = k * phi1 cos_k = np.cos(angle_k) sin_k = np.sin(angle_k) R_phase_k = np.array( [ [cos_k, sin_k], [-sin_k, cos_k], ] ) # Apply: C'_k = (1/scale) * Omega1_inv @ C_k @ R_phase_k C_norm = (1.0 / scale) * Omega1_inv @ C_k @ R_phase_k An[k] = C_norm[0, 0] Bn[k] = C_norm[0, 1] Cn[k] = C_norm[1, 0] Dn[k] = C_norm[1, 1] En[k] = C_norm[2, 0] Fn[k] = C_norm[2, 1] # 4. Direction correction # If the y-sine coefficient of the 1st harmonic is negative, # negate all sine columns if Dn[1] < 0: Bn = -Bn Dn = -Dn Fn = -Fn return An, Bn, Cn, Dn, En, Fn, alpha1, beta1, gamma1, phi1, scale ########################################################### # # set_output API # ########################################################### def __sklearn_is_fitted__(self): """Return True since this is a stateless transformer.""" return True
[docs] def get_feature_names_out( self, input_features: None | npt.ArrayLike = None ) -> np.ndarray: """Get output feature names. Parameters ---------- input_features : ignored Returns ------- feature_names_out : ndarray of str objects Transformed feature names. """ method, _, _, return_extras = self._resolve_registration() include_orientation = ( return_extras and method == "first_order" and self.n_dim in (2, 3) ) return np.asarray(self._build_feature_names(include_orientation), dtype=str)
@property def _n_features_out(self): """Number of transformed output features.""" method, _, _, return_extras = self._resolve_registration() base = (self.n_harmonics + 1) * (2 * self.n_dim) if return_extras and method == "first_order" and self.n_dim in (2, 3): if self.n_dim == 3: return base + 5 if self.n_dim == 2: return base + 2 return base def _build_feature_names(self, include_orientation: bool) -> list[str]: n = self.n_harmonics + 1 # Legacy letter names for the common 1D/2D/3D cases; systematic # ``x{d}_cos_i`` / ``x{d}_sin_i`` names for higher dimensions. legacy_letters = ["a", "b", "c", "d", "e", "f"] feature_names: list[str] = [] if self.n_dim <= 3: for letter in legacy_letters[: 2 * self.n_dim]: feature_names += [f"{letter}_{i}" for i in range(n)] else: for d in range(self.n_dim): feature_names += [f"x{d}_cos_{i}" for i in range(n)] feature_names += [f"x{d}_sin_{i}" for i in range(n)] if include_orientation: if self.n_dim == 3: feature_names += ["alpha", "beta", "gamma", "phi", "scale"] elif self.n_dim == 2: feature_names += ["psi", "scale"] return feature_names
########################################################### # # utility functions # ########################################################### def _coord_names(n_dim: int) -> list[str]: """Return coordinate column names for ``n_dim``-valued reconstructions. Uses ``x``/``y``/``z`` for ``n_dim <= 3`` and systematic ``x0``, ``x1``, ... names otherwise. """ base = ["x", "y", "z"] if n_dim <= len(base): return base[:n_dim] return [f"x{d}" for d in range(n_dim)] def _preprocess_outline(X, t, duplicated_points="infinitesimal"): """Prepare an outline for EFA. Wraps the contour (prepends last point), computes coordinate differences and arc-length parameterization, validates inputs, and handles duplicated (zero-length) segments. Parameters ---------- X : ndarray of shape (n_coords, n_dim) Coordinate values of an outline. t : ndarray of shape (n_coords,) or None Positional parameter. If None, arc-length parameterization is used. duplicated_points : str Strategy for zero-length segments: ``"infinitesimal"`` (default) or ``"deletion"``. Returns ------- X_arr : ndarray of shape (n_coords + 1, n_dim) Wrapped coordinate array (last point prepended). diffs : ndarray of shape (m, n_dim) Per-axis coordinate differences (m <= n_coords after deletion). dt : ndarray of shape (m,) Parameter increments. """ X_arr = np.vstack([X[-1:], np.asarray(X)]) if not np.all(np.isfinite(X_arr)): raise ValueError("Input coordinates must not contain NaN or Inf values.") diffs = X_arr[1:] - X_arr[:-1] if t is None: dt = np.linalg.norm(diffs, axis=1) else: t_ = np.append(0, t) dt = t_[1:] - t_[:-1] tp = np.cumsum(dt) if len(tp) != len(X): raise ValueError( "len(t) must have a same len(X), len(t): " + str(len(tp)) + ", len(X): " + str(len(X)) ) if tp[-1] < _DEGENERACY_TOL: raise ValueError( "Degenerate outline: total arc length is near zero. " "All points may be identical." ) if duplicated_points == "infinitesimal": dt[dt < _INFINITESIMAL_DT] = _INFINITESIMAL_DT elif duplicated_points == "deletion": idx_duplicated_points = np.where(dt == 0)[0] if len(idx_duplicated_points) > 0: diffs = np.delete(diffs, idx_duplicated_points, axis=0) dt = np.delete(dt, idx_duplicated_points) X_arr = np.delete(X_arr, idx_duplicated_points, 0) else: raise ValueError("'duplicated_points' must be 'infinitesimal' or 'deletion'") return X_arr, diffs, dt
[docs] def rotation_matrix_2d(theta): rot_mat = np.array( [[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]] ) return rot_mat
def rotation_matrix_3d_euler_zxz(alpha: float, beta: float, gamma: float) -> np.ndarray: """Construct 3x3 rotation matrix from ZXZ Euler angles. The rotation is composed as Omega = R_gamma @ R_beta @ R_alpha, following the convention in Godefroy et al. (2012) Fig. 1. Parameters ---------- alpha, beta, gamma : float ZXZ Euler angles in radians. Returns ------- rotation_matrix : np.ndarray of shape (3, 3) Orthogonal rotation matrix with determinant +1. """ ca, sa = np.cos(alpha), np.sin(alpha) cb, sb = np.cos(beta), np.sin(beta) cg, sg = np.cos(gamma), np.sin(gamma) return np.array( [ [ca * cg - sa * cb * sg, -ca * sg - sa * cb * cg, sa * sb], [sa * cg + ca * cb * sg, -sa * sg + ca * cb * cg, -ca * sb], [sb * sg, sb * cg, cb], ] ) def _compute_ellipse_geometry_3d( xc: float, xs: float, yc: float, ys: float, zc: float, zs: float, ) -> tuple[float, float, float, float, float, float]: """Compute geometric parameters of a 3D ellipse from Fourier coefficients. Returns the canonical solution with the geometric major axis as the reference: a >= b > 0 (``a`` is the semi-major axis), beta in [0, pi]. The phase ``phi`` is the major-axis phase and is not constrained to ]-pi/4, pi/4[ (constraining it would re-swap a/b). Parameters ---------- xc, xs, yc, ys, zc, zs : float Cosine and sine Fourier coefficients for x, y, z coordinates. Returns ------- phi : float Phase angle of the major axis. a : float Semi-major axis length (a > 0). b : float Semi-minor axis length (b > 0). alpha : float First Euler angle (ZXZ convention). beta : float Second Euler angle, beta in [0, pi]. gamma : float Third Euler angle (ZXZ convention). """ sum_c2 = xc**2 + yc**2 + zc**2 sum_s2 = xs**2 + ys**2 + zs**2 dot_cs = xc * xs + yc * ys + zc * zs # phase angle phi # 2*dot_cs / denom = -tan(2*phi) # -> phi_0 = -(1/2) * arctan2(2*dot_cs, denom) # For a perfect circle both arguments are zero; arctan2(0, 0) = 0.0 denom = sum_c2 - sum_s2 phi_0 = -0.5 * np.arctan2(2 * dot_cs, denom) cos_phi = np.cos(phi_0) sin_phi = np.sin(phi_0) sin_2phi = np.sin(2 * phi_0) a2 = sum_c2 * cos_phi**2 + sum_s2 * sin_phi**2 - dot_cs * sin_2phi b2 = sum_c2 * sin_phi**2 + sum_s2 * cos_phi**2 + dot_cs * sin_2phi # Enforce a >= b; if not, shift phi by pi/2 if a2 < b2: a2, b2 = b2, a2 phi_0 = phi_0 + np.pi / 2 if phi_0 < 0 else phi_0 - np.pi / 2 # Use the major-axis (a >= b) phase directly. Do NOT renormalize phi into # ]-pi/4, pi/4[: that re-swaps a/b and ties the orientation to the phase # branch, flipping the registered shape by pi/2 with the start point. phi = phi_0 a = np.sqrt(max(a2, 0.0)) b = np.sqrt(max(b2, 0.0)) if a < _DEGENERACY_TOL: return 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 cos_phi = np.cos(phi) sin_phi = np.sin(phi) # Recover rotation matrix Omega columns from coefficient vectors. # The parametric equation relates coefficients to Omega and local-frame values: # [xc, yc, zc]^T = Omega @ [a*cos(phi), b*sin(phi), 0]^T # [xs, ys, zs]^T = Omega @ [-a*sin(phi), b*cos(phi), 0]^T # Solving for the first two columns of Omega: # col0 = (cos(phi)*[xc,yc,zc] - sin(phi)*[xs,ys,zs]) / a # col1 = (sin(phi)*[xc,yc,zc] + cos(phi)*[xs,ys,zs]) / b coef_c = np.array([xc, yc, zc]) coef_s = np.array([xs, ys, zs]) col0 = (cos_phi * coef_c - sin_phi * coef_s) / a if b < _DEGENERACY_TOL: col1 = np.zeros(3) col2 = np.zeros(3) else: col1 = (sin_phi * coef_c + cos_phi * coef_s) / b col2 = np.cross(col0, col1) # Rotation matrix entries Omega_11, Omega_21, Omega_31 = col0 Omega_12, Omega_22, Omega_32 = col1 Omega_13, Omega_23, Omega_33 = col2 # Extract Euler angles (ZXZ) from the rotation matrix cos_beta = np.clip(Omega_33, -1.0, 1.0) beta = float(np.arccos(cos_beta)) if abs(np.sin(beta)) < _GIMBAL_TOL: # Gimbal lock gamma = 0.0 alpha = float(np.arctan2(Omega_21, Omega_11)) else: sin_beta = np.sin(beta) gamma = float(np.arctan2(Omega_31 / sin_beta, Omega_32 / sin_beta)) alpha = float(np.arctan2(Omega_13 / sin_beta, -Omega_23 / sin_beta)) return float(phi), float(a), float(b), alpha, beta, float(gamma) def _cse(dx: np.ndarray, dt: np.ndarray, n_harmonics: int) -> np.ndarray: """Cos series expansion n>=1 Parameters ---------- dx : np.ndarray differences of coordinates dt : np.ndarray differences of parameter n_harmonics : int number of harmonics Returns ------- coef : np.ndarray coefficients of cos series expansion """ t = np.concatenate([[0], np.cumsum(dt)]) T = t[-1] cn = [ (T / (2 * (np.pi**2) * (n**2))) * np.sum( (dx / dt) * (np.cos(2 * np.pi * n * t[1:] / T) - np.cos(2 * np.pi * n * t[:-1] / T)) ) for n in range(1, n_harmonics + 1, 1) ] coef = np.array(cn) return coef def _sse(dx: np.ndarray, dt: np.ndarray, n_harmonics: int) -> np.ndarray: """Sin series expansion n>=1""" t = np.concatenate([[0], np.cumsum(dt)]) T = t[-1] cn = [ (T / (2 * (np.pi**2) * (n**2))) * np.sum( (dx / dt) * (np.sin(2 * np.pi * n * t[1:] / T) - np.sin(2 * np.pi * n * t[:-1] / T)) ) for n in range(1, n_harmonics + 1, 1) ] coef = np.array(cn) return coef