-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
MRG: Use mffpy for EGI MFF event reading
#13932
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e465273
5a2248e
e8a58ad
b10ea86
31d8824
b815651
f4119cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Use mffpy-backed parsing for EGI MFF event tracks while tolerating nanosecond timestamps that some files store in ``beginTime``, by `Pragnya Khandelwal`_. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,12 +3,11 @@ | |
| # License: BSD-3-Clause | ||
| # Copyright the MNE-Python contributors. | ||
|
|
||
| from datetime import datetime | ||
| from glob import glob | ||
| from os.path import basename, join, splitext | ||
| from os.path import basename, splitext | ||
|
|
||
| import numpy as np | ||
|
|
||
| from ...fixes import _parse_mffpy_datetime | ||
| from ...utils import _soft_import, _validate_type, logger, warn | ||
|
|
||
|
|
||
|
|
@@ -23,7 +22,9 @@ def _read_events(input_fname, info): | |
| Header info array. | ||
| """ | ||
| n_samples = info["last_samps"][-1] | ||
| mff_events, event_codes = _read_mff_events(input_fname, info["sfreq"]) | ||
| mff_events, event_codes = _read_mff_events( | ||
| input_fname, info["sfreq"], info["meas_dt_local"] | ||
| ) | ||
| info["n_events"] = len(event_codes) | ||
| info["event_codes"] = event_codes | ||
| events = np.zeros([info["n_events"], info["n_segments"] * n_samples]) | ||
|
|
@@ -35,65 +36,99 @@ def _read_events(input_fname, info): | |
| return events, info, mff_events | ||
|
|
||
|
|
||
| def _read_mff_events(filename, sfreq): | ||
| """Extract the events. | ||
| def _read_mff_events(filename, sfreq, start_time): | ||
| """Extract the events with mffpy. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| filename : path-like | ||
| File path. | ||
| sfreq : float | ||
| The sampling frequency | ||
| start_time : datetime | ||
| The recording start time used as the event anchor. | ||
| """ | ||
| orig = {} | ||
| for xml_file in glob(join(filename, "*.xml")): | ||
| xml_type = splitext(basename(xml_file))[0] | ||
| et = _parse_xml(xml_file) | ||
| if et is not None: | ||
| orig[xml_type] = et | ||
| xml_files = orig.keys() | ||
| xml_events = [x for x in xml_files if x[:7] == "Events_"] | ||
| for item in orig["info"]: | ||
| if "recordTime" in item: | ||
| start_time = _ns2py_time(item["recordTime"]) | ||
| break | ||
| # Use defusedxml to parse Events XML directly (avoid mffpy's strict | ||
| # datetime parsing which may include nanosecond fractions). We still use | ||
| # mffpy.Reader for locating the Events.xml files inside the MFF. | ||
| _soft_import("mffpy", "reading EGI MFF data") | ||
| _soft_import("defusedxml", "reading EGI MFF data") | ||
| import defusedxml.ElementTree as DET | ||
| import mffpy | ||
|
|
||
| reader = mffpy.Reader(filename) | ||
| try: | ||
| files_list = sorted(reader.directory.listdir()) | ||
| except Exception: | ||
| files_list = [] | ||
| tracks = [] | ||
| for xml_name in files_list: | ||
| stem = splitext(basename(xml_name))[0] | ||
| if not stem.startswith("Events"): | ||
| continue | ||
| with reader.directory.filepointer(stem) as fp: | ||
| try: | ||
| root = DET.parse(fp).getroot() | ||
| except Exception: | ||
| # fallback: try reading as bytes and parse string | ||
| try: | ||
| fp.seek(0) | ||
| txt = fp.read() | ||
| root = DET.fromstring(txt) | ||
| except Exception as exc2: | ||
| warn( | ||
| f"Could not parse the XML file {xml_name}: {exc2}", | ||
| RuntimeWarning, | ||
| ) | ||
| continue | ||
| # identify eventTrack root (namespace-insensitive) | ||
| if _ns(root.tag) == "eventTrack": | ||
| tracks.append(root) | ||
|
|
||
| markers = [] | ||
| code = [] | ||
| for xml in xml_events: | ||
| for event in orig[xml][2:]: | ||
| event_start = _ns2py_time(event["beginTime"]) | ||
| start = (event_start - start_time).total_seconds() | ||
| if event["code"] not in code: | ||
| code.append(event["code"]) | ||
| marker = { | ||
| "name": event["code"], | ||
| "start": start, | ||
| "start_sample": int(np.trunc(start * sfreq)), | ||
| "end": start + float(event["duration"]) / 1e9, | ||
| "chan": None, | ||
| } | ||
| markers.append(marker) | ||
| events_tims = dict() | ||
| for ev in code: | ||
| trig_samp = list( | ||
| c["start_sample"] for n, c in enumerate(markers) if c["name"] == ev | ||
| ) | ||
| events_tims.update({ev: trig_samp}) | ||
| for root in tracks: | ||
| # each child 'event' element | ||
| for event_el in root.findall("{*}event"): | ||
| # extract fields by tag name ignoring namespace | ||
| ev = {} | ||
| for child in event_el: | ||
| tag = _ns(child.tag) | ||
| ev[tag] = child.text | ||
| # parse times and duration | ||
| event_start = _parse_mffpy_datetime( | ||
| ev.get("beginTime"), tzinfo=start_time.tzinfo | ||
| ) | ||
| if event_start is None: | ||
| continue | ||
| start_sec = (event_start - start_time).total_seconds() | ||
| code_str = ev.get("code", "") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are probably a couple checks you could add:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are really great suggestions. Because this is the very first step of my GSoC project, my mentors requested that I keep this initial micro-PR strictly confined to 1:1 functional parity with MNE's legacy reader. The legacy reader didn't enforce the 4-character limit or map the labels, so I want to avoid expanding the scope just yet. |
||
| if code_str not in code: | ||
| code.append(code_str) | ||
| # duration in xml is typically in nanoseconds | ||
| duration = None | ||
| if ev.get("duration") is not None: | ||
| try: | ||
| duration = int(ev.get("duration")) / 1e9 | ||
| except Exception: | ||
| duration = None | ||
| markers.append( | ||
| { | ||
| "name": code_str, | ||
| "start": start_sec, | ||
| "start_sample": int(np.trunc(start_sec * sfreq)), | ||
| "end": start_sec + (duration if duration is not None else 0.0), | ||
| "chan": None, | ||
| } | ||
| ) | ||
|
|
||
| events_tims = { | ||
| ev: [marker["start_sample"] for marker in markers if marker["name"] == ev] | ||
| for ev in code | ||
| } | ||
| return events_tims, code | ||
|
|
||
|
|
||
| def _parse_xml(xml_file: str) -> list[dict[str, str]] | None: | ||
| """Parse XML file.""" | ||
| defusedxml = _soft_import("defusedxml", "reading EGI MFF data") | ||
| try: | ||
| xml = defusedxml.ElementTree.parse(xml_file) | ||
| except defusedxml.ElementTree.ParseError as e: | ||
| warn(f"Could not parse the XML file {xml_file}: {e}") | ||
| return | ||
| root = xml.getroot() | ||
| return _xml2list(root) | ||
|
|
||
|
|
||
| def _xml2list(root): | ||
| """Parse XML item.""" | ||
| output = [] | ||
|
|
@@ -150,15 +185,6 @@ def _xml2dict(root): | |
| return output | ||
|
|
||
|
|
||
| def _ns2py_time(nstime): | ||
| """Parse times.""" | ||
| nsdate = nstime[0:10] | ||
| nstime0 = nstime[11:26] | ||
| nstime00 = nsdate + " " + nstime0 | ||
| pytime = datetime.strptime(nstime00, "%Y-%m-%d %H:%M:%S.%f") | ||
| return pytime | ||
|
|
||
|
|
||
| def _combine_triggers(data, remapping=None): | ||
| """Combine binary triggers.""" | ||
| new_trigger = np.zeros(data.shape[1]) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason you're using
difusedxmlinstead ofmffpy.XML.from_file( )?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks so much for taking a look! There are two reasons for this:
MNE-Python has a strict internal policy requiring
defusedxmlfor all XML parsing to protect against XML vulnerability attacks.Because of the 9-digit fractional timestamp bug we discussed in issue BEL-Public/mffpy#138 calling
mffpy.XMLdirectly crashes the CI right now. I'm usingdefusedxmlto route the parsing through a temporary shim inmne/fixes.pyuntil your PR gets merged and released!Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The struggle is real as we say. This might be something more for @scott-huberty and @drammock to ponder beyond this GSOC.
The MFF reader in mffpy currently used for reading epochs or evoked files is via
Reader()is already using the mffpyXML.from_file()at least for getting header information and events (mne/io/egi/egimff.py). So there's already some exposure to another XML reader in the current codebase.The pros of keeping the mffpy way of things is just how easy some things are like returning typed objects (.sensors, .epochs, .events),
recover=Truefor malformed XML, and the fact thatReader()is already using it.I suppose we could have a conversation with BEL and other mffpy maintainers about switching the current
lxmlbackend todefusedxml.My thesis is currently: "if the point of this project is to use mffpy to parse MFF files, then you should use mffpy's way of doing things and if those need changing, then we should probably change those upstream". Perhaps the compromise could be using the XML.* functions for this with the goal of changing things upstream in mffpy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm - okay faster than expected I have a mostly working
defusedxmlbackend for mffpy. Might be able to crank this out in a week or so.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@drammock / @larsoner : 1) I assume it's ok if one of our dependencies parses XML files using
lxml?and 2) If mffpy devs cut a release after the aforementioned timestamp bug is fixed upstream... Is there any chance that MNE is comfortable with putting a lower pin on mffpy's latest release, so that we do not have to keep our shim around? 😁
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pins are fine for newly introduced dependencies. It's only an issue if we move from no pin to pin, or bump the pin.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we use defusedxml in our code because it closes some vulnerabilities present in the standard library. We cannot force dependencies to do likewise, but can suggest/encourage it upstream. IIRC, lxml has similar safeguards to defusedxml, but is a much more heavyweight package (does more stuff). I'd need to look into it to be sure though.
If a required dependency used lxml then I'd actually consider switching us to use it too --- why have 2 libs installed if one will suffice? But mffpy will be an optional dep (right?) so that logic doesn't necessarily apply, at least not as strongly, esp if there are salient differences in security or install size or API convenience.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Edit: sorry for the in Felicity of my last message, I just saw that @pmolfese is already looking into what it would take to switch to defusedxml upstream. Thanks! LMK if I can be of help there.