Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
1 change: 1 addition & 0 deletions doc/changes/dev/13489.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added :meth:`mne.Info.to_dict` and :meth:`mne.Info.from_dict` methods to enable JSON serialization and deserialization of :class:`mne.Info` objects, preserving all data types including numpy arrays, datetime objects, and special MNE types, by `Bruno Aristimunha`_.
319 changes: 280 additions & 39 deletions mne/_fiff/meas_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
verbose,
warn,
)
from ..utils._bunch import NamedFloat, NamedInt
from ._digitization import (
DigPoint,
_dig_kind_ints,
Expand Down Expand Up @@ -945,12 +946,33 @@ def __init__(self, *args, **kwargs):
self._unlocked = False

def __getstate__(self):
"""Get state (for pickling)."""
return {"_unlocked": self._unlocked}
"""Get state (for pickling and JSON serialization).

Converts all data to JSON-safe primitives so it can be used for both
pickle and JSON serialization. Pickle will preserve the primitive types,
and JSON can consume them directly.
"""
# Get the dictionary data
state = dict(self)
state["_unlocked"] = self._unlocked

# Convert to JSON-safe format (works for both pickle and JSON)
return _make_serializable(state)

def __setstate__(self, state):
"""Set state (for pickling)."""
self._unlocked = state["_unlocked"]
"""Set state (for unpickling and JSON deserialization).

Restores state from JSON-safe primitives back to native Python/MNE types.
"""
# Restore from JSON-safe format (works for both pickle and JSON)
state = _restore_objects(state)

# Extract _unlocked before updating dict
unlocked = state.pop("_unlocked", True)
self._unlocked = True # Unlock to allow setting
self.clear()
self.update(state)
self._unlocked = unlocked

def __setitem__(self, key, val):
"""Attribute setter."""
Expand Down Expand Up @@ -1172,6 +1194,89 @@ def _check_dev_head_t(dev_head_t, *, info):
return dev_head_t


def _restore_mne_types(info):
"""Restore MNE-specific types after unpickling/deserialization.

This function handles the restoration of MNE-specific object types
that need to be reconstructed from their serialized representations.
These correspond to the "cast" entries in Info._attributes: bads,
dev_head_t, dig, helium_info, line_freq, proj_id, projs, and
subject_info. However, this function is specifically for types that
need restoration because h5io and other serialization formats cast
them to native Python types (e.g., MNEBadsList -> list, Projection
-> dict, DigPoint -> dict).

This function should be called in Info.__init__ and Info.__setstate__.
If new MNE-specific types are added to Info._attributes, they
should be handled here if they need type restoration after
deserialization.

Parameters
----------
info : Info
The Info object whose types need to be restored. Modified in-place.

Notes
-----
This function restores:
- MNEBadsList for the bads field (see Info._attributes["bads"])
- DigPoint objects for digitization points (see Info._attributes["dig"])
- Projection objects for SSP projectors (see Info._attributes["projs"])
- Transform objects for device/head transformations
- meas_date from tuple to datetime
- helium_info and subject_info with proper casting
- proc_history date field from numpy array to tuple (JSON limitation)
"""
# Restore MNEBadsList (corresponds to Info._attributes["bads"])
if "bads" in info:
info["bads"] = MNEBadsList(bads=info["bads"], info=info)

# Format Transform objects
for key in ("dev_head_t", "ctf_head_t", "dev_ctf_t"):
_format_trans(info, key)
for res in info.get("hpi_results", []):
_format_trans(res, "coord_trans")

# Restore DigPoint objects (corresponds to Info._attributes["dig"])
if info.get("dig", None) is not None and len(info["dig"]):
if isinstance(info["dig"], dict): # needs to be unpacked
info["dig"] = _dict_unpack(info["dig"], _DIG_CAST)
if not isinstance(info["dig"][0], DigPoint):
info["dig"] = _format_dig_points(info["dig"])

# Unpack chs if needed
if isinstance(info.get("chs", None), dict):
info["chs"]["ch_name"] = [
str(x) for x in np.char.decode(info["chs"]["ch_name"], encoding="utf8")
]
info["chs"] = _dict_unpack(info["chs"], _CH_CAST)

# Restore Projection objects (corresponds to Info._attributes["projs"])
for pi, proj in enumerate(info.get("projs", [])):
if not isinstance(proj, Projection):
info["projs"][pi] = Projection(**proj)

# Old files could have meas_date as tuple instead of datetime
try:
meas_date = info["meas_date"]
except KeyError:
pass
else:
info["meas_date"] = _ensure_meas_date_none_or_dt(meas_date)

# with validation and casting
for key in ("helium_info", "subject_info"):
if key in info:
info[key] = info[key]

# Restore proc_history[*]['date'] as tuple
# JSON converts tuples to lists, so we need to convert back
for entry in info.get("proc_history", []):
if "date" in entry and isinstance(entry["date"], np.ndarray):
# Convert numpy array back to tuple with Python types
entry["date"] = tuple(int(x) for x in entry["date"])


# TODO: Add fNIRS convention to loc
class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin):
"""Measurement information.
Expand Down Expand Up @@ -1668,44 +1773,19 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._unlocked = True
# Deal with h5io writing things as dict
if "bads" in self:
self["bads"] = MNEBadsList(bads=self["bads"], info=self)
for key in ("dev_head_t", "ctf_head_t", "dev_ctf_t"):
_format_trans(self, key)
for res in self.get("hpi_results", []):
_format_trans(res, "coord_trans")
if self.get("dig", None) is not None and len(self["dig"]):
if isinstance(self["dig"], dict): # needs to be unpacked
self["dig"] = _dict_unpack(self["dig"], _DIG_CAST)
if not isinstance(self["dig"][0], DigPoint):
self["dig"] = _format_dig_points(self["dig"])
if isinstance(self.get("chs", None), dict):
self["chs"]["ch_name"] = [
str(x) for x in np.char.decode(self["chs"]["ch_name"], encoding="utf8")
]
self["chs"] = _dict_unpack(self["chs"], _CH_CAST)
for pi, proj in enumerate(self.get("projs", [])):
if not isinstance(proj, Projection):
self["projs"][pi] = Projection(**proj)
# Old files could have meas_date as tuple instead of datetime
try:
meas_date = self["meas_date"]
except KeyError:
pass
else:
self["meas_date"] = _ensure_meas_date_none_or_dt(meas_date)
self._unlocked = False
# with validation and casting
for key in ("helium_info", "subject_info"):
if key in self:
self[key] = self[key]
with self._unlock():
_restore_mne_types(self)

def __setstate__(self, state):
"""Set state (for pickling)."""
"""Set state (for unpickling and JSON deserialization)."""
# Call parent __setstate__ which will call _restore_objects
# to convert JSON primitives and transform dicts to native types
super().__setstate__(state)
self["bads"] = MNEBadsList(bads=self["bads"], info=self)

# Restore MNE-specific types (requires unlocked state)
# This reconstructs MNEBadsList, DigPoint, Projection, etc.
with self._unlock():
_restore_mne_types(self)

@contextlib.contextmanager
def _unlock(self, *, update_redundant=False, check_after=False):
Expand Down Expand Up @@ -1971,6 +2051,167 @@ def save(self, fname, *, overwrite=False, verbose=None):
"""
write_info(fname, self, overwrite=overwrite)

def to_dict(self):
"""Convert Info to a JSON-serializable dictionary.

This method converts the Info object to a standard Python dictionary
containing only JSON-serializable types (dict, list, str, int, float,
bool, None). Numpy arrays are converted to nested lists, and datetime
objects to ISO format strings.

Returns
-------
dict
A JSON-serializable dictionary representation of the Info object.

See Also
--------
from_dict : Reconstruct Info object from dictionary.

Notes
-----
This method is useful for serializing Info objects to JSON or other
formats that don't support numpy arrays or custom objects.

Examples
--------
>>> info = mne.create_info(['MEG1', 'MEG2'], 1000., ['mag', 'mag'])
>>> info_dict = info.to_dict()
>>> import json
>>> json_str = json.dumps(info_dict) # Save to JSON
"""
# Get JSON-safe state from __getstate__
state = self.__getstate__()

return state

@classmethod
def from_dict(cls, data_dict):
"""Reconstruct Info object from a dictionary.

Parameters
----------
data_dict : dict
A dictionary representation of an Info object, typically
created by the :meth:`to_dict` method.

Returns
-------
Info
The reconstructed Info object.

See Also
--------
to_dict : Convert Info to dictionary.

Examples
--------
>>> info = mne.create_info(['MEG1', 'MEG2'], 1000., ['mag', 'mag'])
>>> info_dict = info.to_dict()
>>> info_restored = mne.Info.from_dict(info_dict)
"""
# Remove version marker
data_dict = data_dict.copy()
# Create empty Info and restore state via __setstate__
# which will handle JSON-safe to native type conversion
info = cls()
info.__setstate__(data_dict)

return info


def _make_serializable(obj):
"""Recursively convert objects to JSON-serializable types."""
from ..transforms import Transform

if obj is None:
return None
elif isinstance(obj, bool):
return obj
elif isinstance(obj, NamedInt):
# Preserve NamedInt with its name
return {"_mne_type": "NamedInt", "value": int(obj), "name": obj._name}
elif isinstance(obj, NamedFloat):
# Preserve NamedFloat with its name
return {"_mne_type": "NamedFloat", "value": float(obj), "name": obj._name}
elif isinstance(obj, (str, int, float)):
return obj
elif isinstance(obj, np.integer):
return int(obj)
elif isinstance(obj, np.floating):
return float(obj)
elif isinstance(obj, np.ndarray):
return obj.tolist()
elif isinstance(obj, datetime.datetime):
# Tag datetime objects for proper reconstruction
return {"_mne_type": "datetime", "value": obj.isoformat()}
elif isinstance(obj, datetime.date):
# Tag date objects for proper reconstruction
return {"_mne_type": "date", "value": obj.isoformat()}
elif isinstance(obj, Transform):
# Tag Transform objects for proper reconstruction
return {
"_mne_type": "Transform",
"from": obj["from"],
"to": obj["to"],
"trans": obj["trans"].tolist(),
}
elif isinstance(obj, (list, tuple)):
return [_make_serializable(item) for item in obj]
elif isinstance(obj, dict):
return {key: _make_serializable(val) for key, val in obj.items()}
else:
# Try to convert to string as fallback
return str(obj)


def _restore_objects(obj):
"""Recursively restore objects from JSON-serializable types."""
if obj is None:
return None
elif isinstance(obj, (bool, int, float)):
return obj
elif isinstance(obj, str):
# Regular strings are returned as-is
return obj
elif isinstance(obj, list):
# Check if all elements are numbers - convert to numpy array
# (JSON doesn't distinguish between tuples and lists, so 1D numeric
# lists that came from numpy arrays should be restored as numpy arrays,
# while tuple fields like proc_history[date] are handled separately)
if len(obj) > 0 and all(isinstance(x, (int, float)) for x in obj):
# 1D numeric arrays should be converted to numpy arrays
return np.array(obj)
elif len(obj) > 0 and all(isinstance(x, list) for x in obj):
# 2D or higher dimensional arrays
return np.array(obj)
else:
# Mixed types - recursively restore elements
return [_restore_objects(item) for item in obj]
elif isinstance(obj, dict):
# Check if this is a tagged MNE type
if "_mne_type" in obj:
if obj["_mne_type"] == "Transform":
# Actually create the Transform object now
from ..transforms import Transform

return Transform(obj["from"], obj["to"], np.array(obj["trans"]))
elif obj["_mne_type"] == "NamedInt":
return NamedInt(obj["name"], obj["value"])
elif obj["_mne_type"] == "NamedFloat":
return NamedFloat(obj["name"], obj["value"])
elif obj["_mne_type"] == "datetime":
# Restore datetime object from ISO format string
return datetime.datetime.fromisoformat(obj["value"])
elif obj["_mne_type"] == "date":
# Restore date object from ISO format string
return datetime.date.fromisoformat(obj["value"])
# Add more types here if needed in the future
else:
return {key: _restore_objects(val) for key, val in obj.items()}
else:
return obj


def _simplify_info(info, *, keep=()):
"""Return a simplified info structure to speed up picking."""
Expand Down
Loading
Loading