Source code for geolatent.core.geometry

"""Geometric utilities for geolatent.

Provides analytical geometry operations used to enrich decision-boundary
visualisations with class-level structural overlays:

* **Confidence ellipsoids** — parametric surfaces derived from the class-
  conditional covariance matrix, scaled to a chosen Mahalanobis-distance
  contour (analogous to a 1-σ / 2-σ region for a Gaussian assumption).
* **Class centroids** — arithmetic means of class point clouds, rendered
  as star markers that anchor the viewer's spatial reference frame.
* **Convex hull traces** (optional, computationally cheap for n ≤ 1000) —
  rendered as transparent mesh surfaces that bound each class cluster.

All methods in :class:`GeometryUtils` operate exclusively in the 3-D projected
space returned by :class:`~geolatent.core.projector.DimensionalityProjector`.
"""

from __future__ import annotations

from typing import Dict, List, Optional

import numpy as np
import plotly.graph_objects as go
from scipy.linalg import cholesky, LinAlgError
from scipy.stats import chi2

from ..config.themes import VisualizationConfig


# Geometry utilities


[docs] class GeometryUtils: """Collection of geometric analysis and Plotly trace generators. All methods accept the 3-D projected coordinate array *X_proj* and the label vector *y*, and return lists of fully configured Plotly traces that can be added directly to a :class:`~geolatent.rendering.scene.Scene3D`. Parameters ---------- config : VisualizationConfig Master configuration; colour palette and render settings are read here. """ def __init__(self, config: VisualizationConfig) -> None: self.config = config # Confidence ellipsoids
[docs] def compute_class_ellipsoids( self, X_proj: np.ndarray, y: np.ndarray, *, confidence: float = 0.90, n_grid: int = 40, class_names: Optional[Dict] = None, ) -> List[go.Surface]: """Generate parametric ellipsoid traces for each class. The ellipsoid is derived from the empirical covariance matrix of the class subset and scaled so that it encloses *confidence* percent of samples under a multivariate-Gaussian assumption. Parameters ---------- X_proj : np.ndarray of shape (n_samples, 3) Projected data coordinates. y : np.ndarray of shape (n_samples,) Class label vector. confidence : float Fraction of the distribution to enclose (e.g., 0.90 → 90 % region). n_grid : int Resolution of the parametric ellipsoid surface mesh. class_names : dict, optional Mapping from class label to display string. Returns ------- traces : list of go.Surface """ colors = self.config.colors.class_colors opacity = self.config.render.ellipsoid_opacity unique_classes = np.unique(y) traces: List[go.Surface] = [] # Chi-squared quantile for the desired confidence level (3 DOF) scale = float(np.sqrt(chi2.ppf(confidence, df=3))) for idx, cls in enumerate(unique_classes): mask = y == cls pts = X_proj[mask] if len(pts) < 4: continue # too few points to estimate a covariance mean = pts.mean(axis=0) cov = np.cov(pts, rowvar=False) try: L = cholesky(cov * (scale ** 2), lower=True) except LinAlgError: # Fall back to regularised covariance cov_reg = cov + np.eye(3) * 1e-6 * np.trace(cov) try: L = cholesky(cov_reg * (scale ** 2), lower=True) except LinAlgError: continue # skip degenerate class # Parametric unit sphere → ellipsoid u = np.linspace(0, 2 * np.pi, n_grid) v = np.linspace(0, np.pi, n_grid) sphere_x = np.outer(np.cos(u), np.sin(v)) sphere_y = np.outer(np.sin(u), np.sin(v)) sphere_z = np.outer(np.ones(n_grid), np.cos(v)) sphere_pts = np.stack( [sphere_x.ravel(), sphere_y.ravel(), sphere_z.ravel()], axis=1 ) # (n_grid², 3) ellipsoid_pts = (L @ sphere_pts.T).T + mean # (n_grid², 3) ex = ellipsoid_pts[:, 0].reshape(n_grid, n_grid) ey = ellipsoid_pts[:, 1].reshape(n_grid, n_grid) ez = ellipsoid_pts[:, 2].reshape(n_grid, n_grid) color = colors[idx % len(colors)] label = self._class_label(cls, class_names) traces.append( go.Surface( x=ex, y=ey, z=ez, colorscale=[[0, color], [1, color]], showscale=False, opacity=opacity, name=f"{label}{int(confidence * 100)}% ellipsoid", hoverinfo="name", lighting=dict( ambient=0.7, diffuse=0.4, specular=0.1, roughness=0.9, ), showlegend=True, ) ) return traces
# Class centroids
[docs] def compute_class_centroids( self, X_proj: np.ndarray, y: np.ndarray, *, class_names: Optional[Dict] = None, ) -> go.Scatter3d: """Build a single Scatter3d trace for all class centroids. Parameters ---------- X_proj : np.ndarray of shape (n_samples, 3) y : np.ndarray of shape (n_samples,) class_names : dict, optional Returns ------- trace : go.Scatter3d Star-shaped markers at the class-mean coordinates. """ colors = self.config.colors.class_colors unique_classes = np.unique(y) cx, cy, cz, c_colors, c_labels = [], [], [], [], [] for idx, cls in enumerate(unique_classes): mask = y == cls mean = X_proj[mask].mean(axis=0) cx.append(float(mean[0])) cy.append(float(mean[1])) cz.append(float(mean[2])) c_colors.append(colors[idx % len(colors)]) c_labels.append(self._class_label(cls, class_names)) return go.Scatter3d( x=cx, y=cy, z=cz, mode="markers+text", marker=dict( symbol="diamond", size=self.config.render.centroid_marker_size, color=c_colors, line=dict( color=self.config.colors.centroid_color, width=2, ), ), text=c_labels, textposition="top center", textfont=dict( color=self.config.colors.text, size=11, family=self.config.render.font_family, ), name="Class centroids", hovertemplate=( "<b>%{text}</b><br>" "x: %{x:.3f}<br>y: %{y:.3f}<br>z: %{z:.3f}<extra></extra>" ), )
# Convex hulls
[docs] def compute_convex_hull_traces( self, X_proj: np.ndarray, y: np.ndarray, *, class_names: Optional[Dict] = None, ) -> List[go.Mesh3d]: """Generate convex-hull surface traces for each class cluster. Parameters ---------- X_proj : np.ndarray of shape (n_samples, 3) y : np.ndarray of shape (n_samples,) class_names : dict, optional Returns ------- traces : list of go.Mesh3d One mesh per class, rendered at very low opacity. """ try: from scipy.spatial import ConvexHull except ImportError: return [] colors = self.config.colors.class_colors unique_classes = np.unique(y) traces: List[go.Mesh3d] = [] for idx, cls in enumerate(unique_classes): pts = X_proj[y == cls] if len(pts) < 4: continue try: hull = ConvexHull(pts) except Exception: # noqa: BLE001 continue vertices = pts[hull.vertices] simplices = hull.simplices # Re-index simplices to refer to the hull.vertices subset vertex_map = {orig: new for new, orig in enumerate(hull.vertices)} ti = np.array( [[vertex_map[s] for s in simplex] for simplex in simplices] ) color = colors[idx % len(colors)] label = self._class_label(cls, class_names) traces.append( go.Mesh3d( x=vertices[:, 0], y=vertices[:, 1], z=vertices[:, 2], i=ti[:, 0], j=ti[:, 1], k=ti[:, 2], color=color, opacity=0.07, name=f"{label} — convex hull", hoverinfo="name", flatshading=True, showlegend=True, ) ) return traces
# Private helpers @staticmethod def _class_label(cls: object, class_names: Optional[Dict]) -> str: if class_names and cls in class_names: return str(class_names[cls]) return f"Class {cls}"