Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- The factory method `cedalion.dot.get_standard_headmodel` to construct the `TwoSurfaceHeadModel` of the standard Colin27 and ICBM-152 heads was added, by [Eike Middell](https://github.com/emiddell).
- Added `cedalion.xrutils.dot_dataarray_csr` for matrix products between `xr.DataArray`
and `scipy.sparse` arrays, by [Eike Middell](https://github.com/emiddell).
- Added `cedalion.geometry.landmarks.normalize_landmarks_labels` to map alternative landmark names (e.g., "nasion", "left ear", "nz") to their canonical 10-10 system labels (e.g. Nz, LPA). The function handles now case-insensitive matching and supports common naming conventions. Usage: `geo3d = normalize_landmarks_labels(geo3d)` before calling registration or plotting functions, by [Mohammad Orabe](https://github.com/orabe). ([#84](https://github.com/ibs-lab/cedalion/issues/84))
### Changed
- The package `cedalion.sigproc.motion_correct` was renamed to `cedalion.sigproc.motion`.
- The ICA-EBM and ICA_ERBM implementations were moved into `cedalion.sigdecomp.unimodal`.
Expand All @@ -35,6 +36,7 @@ access example datasets are now available under `cedalion.data`.
coordinate system, by [Nils Harmening](https://github.com/harmening). ([#110](https://github.com/ibs-lab/cedalion/pull/110))
- Changed the names of several motion correction algorithms from `motion_correct.motion_correct_X`
to `motion_correct.X`. Argument names were made PEP8 compliant. The example `22_motion_artefacts_and_correction` was improved. By [Eike Middell](https://github.com/emiddell).
- The function `cedalion.vis.anatomy.plot_montage3D` now accepts a `landmarks` parameter to specify which landmarks should be highlighted. Pass `None` (default) to show all available canonical registration landmarks (e.g. Nz, Iz, LPA, RPA, Cz), a list of landmark names to show specific ones, or an empty list to show none, by [Mohammad Orabe](https://github.com/orabe). ([#84](https://github.com/ibs-lab/cedalion/issues/84))


### Deprecated
Expand Down
64 changes: 64 additions & 0 deletions src/cedalion/geometry/landmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,70 @@
from cedalion.typing import LabeledPoints


def normalize_landmarks_labels(geo3d: LabeledPoints) -> LabeledPoints:
"""Normalize landmark labels to canonical names.

Maps commonly used alternative landmark names to canonical names:
- NASION, Nasion, nasion, nas, Nas, NAS -> Nz
- INION, Inion, inion, ini, Ini, INI -> Iz
- LPA_L, lpa, left ear, Left Ear, LEFT EAR, LE, left, Left, L -> LPA
- RPA_R, rpa, right ear, Right Ear, RIGHT EAR, RE, right, Right, R -> RPA
- CZ, cz, vertex, Vertex, VERTEX -> Cz

When multiple labels normalize to the same canonical name:
- If the canonical name already exists, alternative forms are dropped
- If multiple alternatives exist without the canonical form, only the
first one is renamed and others are dropped

Args:
geo3d: LabeledPoints with potentially non-canonical landmark names.

Returns:
LabeledPoints with normalized landmark labels (duplicates removed).
"""
if len(geo3d.label) == 0:
return geo3d

label_mapping = {
"Nz": {"NASION", "Nasion", "nasion", "nas", "Nas", "NAS"},
"Iz": {"INION", "Inion", "inion", "ini", "Ini", "INI"},
"LPA": {"LPA_L", "lpa", "left ear", "Left Ear", "LEFT EAR", "LE", "left", "Left", "L"},
"RPA": {"RPA_R", "rpa", "right ear", "Right Ear", "RIGHT EAR", "RE", "right", "Right", "R"},
"Cz": {"CZ", "cz", "vertex", "Vertex", "VERTEX"},
}

existing_labels = set(geo3d.label.values)

labels_to_rename = {} # {alternative_label: canonical_label}
labels_to_drop = []

for canonical, alternatives in label_mapping.items():
# Find which alternative forms are present in the data
present_alternatives = [lbl for lbl in existing_labels if lbl in alternatives]

if len(present_alternatives) > 0:
if canonical in existing_labels:
# drop all alternative forms if canonical already exists
labels_to_drop.extend(present_alternatives)
else:
# If no canonical form: rename first alternative, drop rest
labels_to_rename[present_alternatives[0]] = canonical
if len(present_alternatives) > 1:
labels_to_drop.extend(present_alternatives[1:])

# Apply transformations: first drop duplicates, then rename
if labels_to_drop:
keep_labels = [label for label in geo3d.label.values
if label not in labels_to_drop]
geo3d = geo3d.sel(label=keep_labels)

# Rename alternative labels to canonical names
if labels_to_rename:
geo3d = geo3d.points.rename(labels_to_rename)

return geo3d


def _sort_line_points(start_point: np.ndarray, points: np.ndarray):
sorted_indices = []
sorted_distances = []
Expand Down
47 changes: 41 additions & 6 deletions src/cedalion/vis/anatomy/montage.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,68 @@
import cedalion.typing as cdt
from cedalion.dataclasses.geometry import PointType
import matplotlib.pyplot as p


def plot_montage3D(amp: cdt.NDTimeSeries, geo3d: cdt.LabeledPoints):
def plot_montage3D(
amp: cdt.NDTimeSeries,
geo3d: cdt.LabeledPoints,
landmarks: list[str] | None = None
):
"""Plots a 3D visualization of a montage.

Args:
amp: Time series data array.
geo3d: Landmark coordinates.
landmarks: Landmarks to highlight in the plot. Can be:
- None (default): Shows canonical registration landmarks (Nz, Iz, LPA, RPA, Cz)
- list of str: Shows specific landmarks (only if they exist in geo3d)
- []: Empty list shows no landmarks
"""
geo3d = geo3d.pint.dequantify()

f = p.figure()
ax = f.add_subplot(projection="3d")
colors = ["r", "b", "gray"]
sizes = [20, 20, 2]
for i, (type, x) in enumerate(geo3d.groupby("type")):
ax.scatter(x[:, 0], x[:, 1], x[:, 2], c=colors[i], s=sizes[i])
for i, (point_type, x) in enumerate(geo3d.groupby("type")):
if len(x) > 0:
ax.scatter(x[:, 0], x[:, 1], x[:, 2], c=colors[i], s=sizes[i])

# Draw lines connecting sources to detectors for each channel
for i in range(amp.sizes["channel"]):
src = geo3d.loc[amp.source[i], :]
det = geo3d.loc[amp.detector[i], :]
ax.plot([src[0], det[0]], [src[1], det[1]], [src[2], det[2]], c="k")

# if available mark Nasion in yellow
if "Nz" in geo3d.label:
# Determine which landmarks to highlight
if landmarks is None:
# Default: show canonical registration landmarks
canonical_landmarks = ["Nz", "Iz", "LPA", "RPA", "Cz"]
landmarks_to_plot = [
label for label in canonical_landmarks
if label in geo3d.label.values
]
else:
# Show specified landmarks (filter non-existent ones)
landmarks_to_plot = [
label for label in landmarks
if label in geo3d.label.values
]

landmark_colors = ["y", "m", "c", "orange", "lime", "pink", "brown", "purple"]
for idx, label in enumerate(landmarks_to_plot):
color = landmark_colors[idx % len(landmark_colors)]
ax.scatter(
geo3d.loc["Nz", 0], geo3d.loc["Nz", 1], geo3d.loc["Nz", 2], c="y", s=25
geo3d.loc[label, 0],
geo3d.loc[label, 1],
geo3d.loc[label, 2],
c=color,
s=50,
label=label
)

if landmarks_to_plot:
ax.legend(bbox_to_anchor=(0, 0.5), loc='center right')

ax.view_init(elev=30, azim=145)
p.tight_layout()
Loading
Loading