# -*- coding: utf-8 -*-
"""
spectral_deformation.py — SpectralBrain
=========================================
Quantificação e visualização de deformação NÃO-ISOMÉTRICA entre duas superfícies
via variação do espectro de Laplace-Beltrami, *sem registro e sem correspondência*
(Hu, Hamidian, Zhong & Hua, IEEE TVCG 2017,
"Visualizing Shape Deformations with Variation of Geometric Spectrum").
Caso de uso clínico (MTLE-HS): lateralização intrínseca esquerda-vs-direita do
hipocampo. O método é *correspondence-free* e o espectro é invariante a isometrias
e a reflexões — então L-vs-R sai direto, sem espelhar nem registrar, mesmo com
malhas de contagens de vértices diferentes.
────────────────────────────────────────────────────────────────────────────────
A MATEMÁTICA (resumo fiel ao paper)
────────────────────────────────────────────────────────────────────────────────
LBO discreto: W v = λ S v (W = stiffness cotangente; S = massa Voronoi/lumped)
Deformação = uma função de escala positiva por-vértice ω (matriz diagonal Ω) na
métrica de Riemann: gω = ω·g. O problema de autovalor ponderado fica
W v_i = λ_i (Ω S) v_i, <v_i, v_i>_{ΩS} = 1.
Teorema 2 (derivada do autovalor): λ̇_i = −λ_i · v_iᵀ Ω̇ S v_i.
Interpolamos os autovalores de N linearmente até os de M, o que dá um sistema
linear na derivada da escala (via produto de Hadamard, Eq. 18 do paper):
(m_N ∘ v_i ∘ v_i)ᵀ · d = (λ_i(q) − λ_M,i) / λ_i(q), i = 1..k1
onde d = diag(Ω̇) é a incógnita por-vértice e m_N = diag(S_N) são as áreas.
Empilhando i=1..k1: A·d = b. O sistema é sub-determinado (k1 ≪ n), então
escolhemos a solução de menor energia de suavização (Eq. 25):
min_d dᵀ W d + 2 cᵀ d , c = W·v_Ω
s.a. A d = b (alinhamento dos autovalores)
h_l ≤ v_Ω + d ≤ h_u (escala positiva e limitada)
Integração linear em K passos, re-inicializando o espectro a cada passo (Eq. 31):
Ω(q+1) = Ω(q) + 1/(K−q) · Ω̇(q), Ω(0) = I.
Saída: a função de escala ω por-vértice em N. Visualiza-se log(ω): positivo =
N precisa expandir localmente para casar com M; negativo = contrair. As nodal
lines da deformação (log ω = 0) são o que importa — "topology over magnitude".
────────────────────────────────────────────────────────────────────────────────
Decisões de engenharia (vs. o paper de 2017)
────────────────────────────────────────────────────────────────────────────────
• Operadores cotangente + massa Voronoi-lumped implementados aqui (numpy/scipy),
sem dependência externa para o núcleo.
• O QP é resolvido com OSQP (esparso). A restrição de alinhamento entra como
igualdade no solver — NUNCA formamos AᵀA denso (n×n estouraria a RAM em malhas
de 8k vértices). Há um `align_tol` para relaxar a igualdade numa banda (modo
"soft"), mais estável quando os limites apertam.
• `multiscale`: varrer k1 crescente revela deformações de frequência crescente
(k1 baixo → global; k1 alto → fino), no espírito multiescala da SB.
Dependências: numpy, scipy, osqp (pip install osqp). Opcional: vedo (render).
Autor: Rodrigo Debona (Velho Mago) — com Claudinho.
"""
# %% ───────────────────────────── imports & ambiente ─────────────────────────
import os
import warnings
from typing import Dict, Optional, Sequence, Tuple, Union
import numpy as np
import scipy.sparse as sp
from scipy.sparse.linalg import eigsh
ArrayF = np.ndarray
ArrayI = np.ndarray
PathLike = Union[str, os.PathLike]
# %% ───────────────────── operadores LBO: stiffness & massa ───────────────────
def cotangent_stiffness(V: ArrayF, F: ArrayI) -> sp.csc_matrix:
"""Matriz de rigidez cotangente W (n×n), convenção do paper (Eq. 4).
Off-diagonal W_ij = −(cot α_ij + cot β_ij)/2 ; diagonal W_ii = −Σ_j W_ij > 0.
Forma quadrática vᵀ W v = energia de Dirichlet ≥ 0 (PSD em malhas não-obtusas).
"""
V = np.asarray(V, dtype=float)
F = np.asarray(F, dtype=int)
n = V.shape[0]
i1, i2, i3 = F[:, 0], F[:, 1], F[:, 2]
v1, v2, v3 = V[i1], V[i2], V[i3]
# área dupla por face (norma do produto vetorial)
dblA = np.linalg.norm(np.cross(v2 - v1, v3 - v1), axis=1)
dblA = np.maximum(dblA, 1e-12)
# cotangentes dos ângulos em cada vértice da face
cot1 = np.einsum("ij,ij->i", v2 - v1, v3 - v1) / dblA # ângulo em i1
cot2 = np.einsum("ij,ij->i", v3 - v2, v1 - v2) / dblA # ângulo em i2
cot3 = np.einsum("ij,ij->i", v1 - v3, v2 - v3) / dblA # ângulo em i3
# o cot do ângulo em i pondera a aresta OPOSTA (Eq. 2/4)
I = np.concatenate([i2, i3, i3, i1, i1, i2])
J = np.concatenate([i3, i2, i1, i3, i2, i1])
vals = 0.5 * np.concatenate([cot1, cot1, cot2, cot2, cot3, cot3])
W = sp.csr_matrix((-vals, (I, J)), shape=(n, n)) # off-diagonais (negativas)
diag = -np.asarray(W.sum(axis=1)).ravel() # diagonal = −Σ off-diag (>0)
W = (W + sp.diags(diag)).tocsc()
W = 0.5 * (W + W.T) # simetriza erros numéricos
return W.tocsc()
def lumped_mass(V: ArrayF, F: ArrayI) -> ArrayF:
"""Massa lumped (barycêntrica) como vetor diagonal m (n,).
m_i = (1/3) Σ_{faces incidentes} área. Estável e padrão para LBO lumped; é a
diagonal exigida pela linearização de Hadamard (Eq. 18).
"""
V = np.asarray(V, dtype=float)
F = np.asarray(F, dtype=int)
n = V.shape[0]
v1, v2, v3 = V[F[:, 0]], V[F[:, 1]], V[F[:, 2]]
area = 0.5 * np.linalg.norm(np.cross(v2 - v1, v3 - v1), axis=1)
m = np.zeros(n)
for c in range(3):
np.add.at(m, F[:, c], area / 3.0)
m = np.maximum(m, 1e-12)
return m
# %% ─────────────────── autoproblema generalizado ponderado ───────────────────
def _weighted_eigs(
W: sp.csc_matrix,
mass_diag: ArrayF,
k_nonzero: int,
sigma: float = -1e-6,
) -> Tuple[ArrayF, ArrayF]:
"""Resolve W v = λ (B) v, B = diag(mass_diag), retornando os k_nonzero
menores autopares NÃO-NULOS (pula o modo constante λ≈0).
Autovetores vêm B-ortonormais (vᵀ B v = I), que é a normalização <·,·>_{ΩS}=1.
"""
B = sp.diags(mass_diag).tocsc()
vals, vecs = eigsh(W, k=k_nonzero + 1, M=B, sigma=sigma, which="LM")
order = np.argsort(vals)
vals, vecs = vals[order], vecs[:, order]
# descarta o primeiro (constante, λ≈0)
return vals[1:k_nonzero + 1], vecs[:, 1:k_nonzero + 1]
# %% ──────────────────────────── passo de QP (OSQP) ───────────────────────────
def _qp_step(
W: sp.csc_matrix,
v_omega: ArrayF,
A: ArrayF,
b: ArrayF,
h_low: ArrayF,
h_high: ArrayF,
align_tol: float,
ridge: float = 1e-8,
) -> ArrayF:
"""Um passo do QP (Eq. 19/25/27) via OSQP esparso.
min_d dᵀ W d + 2 cᵀ d , c = W v_Ω
s.a. b−tol ≤ A d ≤ b+tol (alinhamento; tol=0 => igualdade dura)
h_l−v_Ω ≤ d ≤ h_u−v_Ω (limites de escala)
A igualdade é mantida como restrição do solver — AᵀA denso NUNCA é formado.
"""
try:
import osqp
except Exception as exc: # pragma: no cover
raise ImportError(
"OSQP não encontrado. Instale com `pip install osqp`."
) from exc
n = W.shape[0]
P = (2.0 * W + ridge * sp.identity(n, format="csc")).tocsc()
q = 2.0 * (W.dot(v_omega))
A_align = sp.csc_matrix(A) # (k1 × n)
C = sp.vstack([A_align, sp.identity(n, format="csc")], format="csc")
lo = np.concatenate([b - align_tol, h_low - v_omega])
hi = np.concatenate([b + align_tol, h_high - v_omega])
prob = osqp.OSQP()
prob.setup(P=P, q=q, A=C, l=lo, u=hi, verbose=False,
polish=True, eps_abs=1e-6, eps_rel=1e-6, max_iter=8000)
res = prob.solve()
status = res.info.status_val
if status not in (1, 2): # 1=solved, 2=solved_inaccurate
warnings.warn(
f"OSQP não convergiu plenamente (status={res.info.status}). "
"Considere aumentar `align_tol` (modo soft) ou afrouxar os limites."
)
d = res.x
if d is None or not np.all(np.isfinite(d)):
warnings.warn("QP retornou solução inválida; passo zerado.")
return np.zeros(n)
return np.asarray(d, dtype=float)
# %% ─────────────────────── algoritmo principal de deformação ─────────────────
# %% ─────────────────── wrapper clínico: lateralização MTLE-HS ────────────────
def lateralization_map(
V_ipsi: ArrayF, F_ipsi: ArrayI,
V_contra: ArrayF, F_contra: ArrayI,
k1: int = 100,
n_steps: int = 10,
bounds: Tuple[float, float] = (0.1, 10.0),
align_tol: float = 0.0,
) -> Dict[str, object]:
"""Mapa de lateralização L-vs-R do hipocampo, definido na malha IPSILATERAL.
Convenção MTLE-HS: N = ipsilateral (lado da esclerose), M = contralateral.
log(ω) > 0 marca onde o ipsi precisa expandir para casar com o contra — ou
seja, regiões de ATROFIA relativa do ipsi (precisa "crescer" para igualar).
log(ω) < 0 marca regiões onde o ipsi está relativamente maior.
Como o método é correspondence-free e reflection-invariant, NÃO espelhe nem
registre as malhas; passe-as como saem do HippUnfold/segmentação.
"""
res = spectral_deformation(
V_ipsi, F_ipsi, V_contra, F_contra,
k1=k1, n_steps=n_steps, bounds=bounds, align_tol=align_tol,
)
res["convention"] = "N=ipsilateral, M=contralateral; log(ω)>0 => atrofia relativa do ipsi"
return res
# %% ─────────────────────────── render (vedo, offscreen) ──────────────────────
def render_scale_on_mesh(
V: ArrayF, F: ArrayI,
scalar: ArrayF,
out_path: PathLike = "spectral_deformation.png",
cmap: str = "RdBu_r",
percentile_clim: float = 98.0,
symmetric: bool = True,
title: Optional[str] = None,
size: Tuple[int, int] = (1200, 900),
zoom: float = 1.3,
) -> str:
"""Pinta um campo escalar por-vértice (tipicamente log ω) sobre a malha.
Divergente azul-branco-vermelho, clim simétrico por percentil (robusto a
outliers), render offscreen via vedo. Serve para qualquer estrutura
(hipocampo, subcortical, etc.) — não exige template fsLR.
"""
try:
import vedo
except Exception as exc: # pragma: no cover
raise ImportError("vedo não encontrado. Instale com `pip install vedo`.") from exc
s = np.asarray(scalar, dtype=float)
finite = s[np.isfinite(s)]
vmax = np.percentile(np.abs(finite), percentile_clim) if finite.size else 1.0
vmax = float(vmax) if vmax > 0 else 1.0
vmin = -vmax if symmetric else float(np.percentile(finite, 100 - percentile_clim))
mesh = vedo.Mesh([np.asarray(V, float), np.asarray(F, int)])
mesh.cmap(cmap, s, vmin=vmin, vmax=vmax).add_scalarbar(title=title or "log ω")
plt = vedo.Plotter(offscreen=True, size=size)
plt.show(mesh, zoom=zoom, axes=0)
plt.screenshot(str(out_path))
plt.close()
print(f"render salvo em: {out_path}")
return str(out_path)
# %% ─────────────────────────────── exemplo de uso ───────────────────────────
if __name__ == "__main__":
# Carregue suas malhas hipocampais (V: (n,3) float, F: (m,3) int).
# Ex.: via nibabel GIfTI, trimesh, ou o I/O da SpectralBrain.
#
# import nibabel as nib
# g = nib.load("sub-XXX_hemi-L_hipp.surf.gii")
# V_L = g.darrays[0].data; F_L = g.darrays[1].data
# (idem para o hemi-R)
#
# Convenção: N = ipsilateral, M = contralateral.
# --- placeholder mínimo só para o módulo ser executável sem dados ---
# (substitua por V_ipsi/F_ipsi e V_contra/F_contra reais)
print("Carregue as malhas reais e chame lateralization_map(...). Exemplo:")
print(
"res = lateralization_map(V_ipsi, F_ipsi, V_contra, F_contra, k1=100)\n"
"render_scale_on_mesh(V_ipsi, F_ipsi, res['log_scale'],\n"
" out_path='lat_ipsi.png', title='log ω (ipsi→contra)')"
)
# Diagnóstico esperado: res['align_error'] deve decair monotonicamente ao
# longo dos K passos (autovalores de N convergindo aos de M).