diff --git a/doc/changes/dev/13489.newfeature.rst b/doc/changes/dev/13489.newfeature.rst new file mode 100644 index 00000000000..27fe2587705 --- /dev/null +++ b/doc/changes/dev/13489.newfeature.rst @@ -0,0 +1 @@ +Added :meth:`mne.Info.to_json_dict` and :meth:`mne.Info.from_json_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`_. diff --git a/mne/_fiff/meas_info.py b/mne/_fiff/meas_info.py index 95a9f04e6f1..e6d7bd4690a 100644 --- a/mne/_fiff/meas_info.py +++ b/mne/_fiff/meas_info.py @@ -35,6 +35,7 @@ verbose, warn, ) +from ..utils._bunch import NamedFloat, NamedInt from ._digitization import ( DigPoint, _dig_kind_ints, @@ -1172,6 +1173,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. @@ -1668,39 +1752,8 @@ 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).""" @@ -1971,6 +2024,166 @@ def save(self, fname, *, overwrite=False, verbose=None): """ write_info(fname, self, overwrite=overwrite) + def to_json_dict(self) -> dict: + """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_json_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_json_dict() + >>> import json + >>> json_str = json.dumps(info_dict) # Save to JSON + """ + return _make_serializable(self) + + @classmethod + def from_json_dict(cls, data_dict) -> "Info": + """Reconstruct Info object from a dictionary. + + Parameters + ---------- + data_dict : dict + A dictionary representation of an Info object, typically + created by the :meth:`to_json_dict` method. + + Returns + ------- + Info + The reconstructed Info object. + + See Also + -------- + to_json_dict : Convert Info to dictionary. + + Examples + -------- + >>> info = mne.create_info(['MEG1', 'MEG2'], 1000., ['mag', 'mag']) + >>> info_dict = info.to_json_dict() + >>> info_restored = mne.Info.from_json_dict(info_dict) + """ + data_dict = data_dict.copy() + # Restore all nested objects (Transform, NamedInt, etc.) + restored_dict = _restore_objects(data_dict) + + info = cls() + with info._unlock(): + info.update(restored_dict) + _restore_mne_types(info) + + 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) -> object: + """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.""" diff --git a/mne/_fiff/tests/test_meas_info.py b/mne/_fiff/tests/test_meas_info.py index 119829fce0a..4e409d262e0 100644 --- a/mne/_fiff/tests/test_meas_info.py +++ b/mne/_fiff/tests/test_meas_info.py @@ -2,6 +2,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import json import pickle import string from datetime import date, datetime, timedelta, timezone @@ -61,7 +62,7 @@ ) from mne.datasets import testing from mne.event import make_fixed_length_events -from mne.io import BaseRaw, RawArray, read_raw_ctf, read_raw_fif +from mne.io import BaseRaw, RawArray, read_raw_ctf, read_raw_edf, read_raw_fif from mne.minimum_norm import ( apply_inverse, make_inverse_operator, @@ -353,6 +354,136 @@ def test_read_write_info(tmp_path): write_info(fname, info, overwrite=True) +@testing.requires_testing_data +def test_info_serialization_roundtrip(tmp_path): + """Test Info JSON serialization with real MEG data.""" + # Test with real MEG/FIF file + raw = read_raw_fif(raw_fname, preload=False, verbose=False) + _complete_info(raw.info) + info = raw.info.copy() + + # Save to JSON + json_path = tmp_path / "info.json" + with open(json_path, "w") as f: + json.dump(info.to_json_dict(), f) + + # Read back from JSON + with open(json_path) as f: + info_dict = json.load(f) + info_restored = Info.from_json_dict(info_dict) + + # Verify everything is exactly the same + assert_object_equal(info, info_restored) + + +def test_info_serialization_edf(tmp_path): + """Test Info JSON serialization with EDF data.""" + edf_path = root_dir / "io" / "edf" / "tests" / "data" / "test.edf" + raw = read_raw_edf(edf_path, preload=False, verbose=False) + info = raw.info.copy() + + # Save to JSON + json_path = tmp_path / "info_edf.json" + with open(json_path, "w") as f: + json.dump(info.to_json_dict(), f) + + # Read back from JSON + with open(json_path) as f: + info_dict = json.load(f) + info_restored = Info.from_json_dict(info_dict) + + # Verify everything is exactly the same + assert_object_equal(info, info_restored) + + +def test_info_serialization_special_types(): + """Test that special types (NamedInt, dates, etc.) are preserved correctly.""" + from mne.utils._bunch import NamedInt + + # Create info with various special types + info = create_info(ch_names=["EEG1"], sfreq=1000.0, ch_types="eeg") + + # Test meas_date (datetime) + meas_date = datetime(2023, 11, 13, 10, 30, 0, tzinfo=timezone.utc) + with info._unlock(): + info["meas_date"] = meas_date + + # Test subject_info with birthday (date) + info["subject_info"] = { + "id": 1, + "his_id": "SUBJ001", + "birthday": date(1990, 1, 15), + "sex": 1, + } + + # Roundtrip through JSON + info_dict = info.to_json_dict() + json_str = json.dumps(info_dict) + info_restored = Info.from_json_dict(json.loads(json_str)) + + # Verify special types are preserved + assert isinstance(info_restored["meas_date"], datetime) + assert info_restored["meas_date"] == meas_date + assert isinstance(info_restored["subject_info"]["birthday"], date) + assert info_restored["subject_info"]["birthday"] == date(1990, 1, 15) + assert isinstance(info_restored["custom_ref_applied"], NamedInt) + assert repr(info["custom_ref_applied"]) == repr(info_restored["custom_ref_applied"]) + + +@testing.requires_testing_data +def test_info_serialization_numpy_arrays(tmp_path): + """Test that numpy arrays (e.g., compensation matrices) serialize correctly.""" + # Use CTF data which has compensation matrices + raw = read_raw_ctf(ctf_fname, preload=False, verbose=False) + info = raw.info.copy() + + # Verify we have compensation data with matrices + assert len(info["comps"]) > 0, "CTF data should have compensation matrices" + + # Check the structure of compensation matrices before serialization + for comp in info["comps"]: + assert "data" in comp + assert "data" in comp["data"] + comp_matrix = comp["data"]["data"] + assert isinstance(comp_matrix, np.ndarray), ( + "Compensation matrix should be numpy array" + ) + assert comp_matrix.ndim == 2, "Compensation matrix should be 2D" + assert comp_matrix.shape[0] > 0 and comp_matrix.shape[1] > 0 + + # Save to JSON + json_path = tmp_path / "info_with_comps.json" + with open(json_path, "w") as f: + json.dump(info.to_json_dict(), f) + + # Read back from JSON + with open(json_path) as f: + info_dict = json.load(f) + info_restored = Info.from_json_dict(info_dict) + + # Verify compensation matrices are preserved correctly + assert len(info_restored["comps"]) == len(info["comps"]) + + for orig_comp, rest_comp in zip(info["comps"], info_restored["comps"]): + orig_matrix = orig_comp["data"]["data"] + rest_matrix = rest_comp["data"]["data"] + + # Verify it's a numpy array with correct shape + assert isinstance(rest_matrix, np.ndarray) + assert rest_matrix.shape == orig_matrix.shape + assert rest_matrix.ndim == 2 + + # Verify the actual values are preserved + assert_allclose(rest_matrix, orig_matrix, rtol=1e-10) + + # Verify row and column names are preserved + assert orig_comp["data"]["row_names"] == rest_comp["data"]["row_names"] + assert orig_comp["data"]["col_names"] == rest_comp["data"]["col_names"] + + # Use assert_object_equal for comprehensive check + assert_object_equal(info, info_restored) + + @testing.requires_testing_data def test_dir_warning(): """Test that trying to read a bad filename emits a warning before an error."""